/** * scripts/validate-manifest.mjs — 插件 manifest.json 零依赖校验器(core-protocol-v0) * owner:T1b-α lane-core | 运行:node scripts/validate-manifest.mjs [...] * 缺省:自动扫描 src/plugins/<*>/manifest.json 全部校验。 * * 【为什么不引 ajv】 * 本机零 npm 依赖纪律(执行版 §1)。draft-07 全量校验需 ajv —— 违纪。本脚本只对 * `schema/plugin-manifest.schema.json` 的**关键约束**做手写校验(required / additionalProperties:false / * type / pattern / minimum-maximum / enum-uniqueItems),覆盖该 schema 实际用到的全部规则, * 足够把「manifest 是否合契约」判明并留证。集成段如需全量 draft-07 校验可另接 ajv(属工具链,集成段跑)。 * * 退出码:全部通过=0;任一不合规=1(CI/收口可据此 gate)。 */ 'use strict'; import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; // 定位仓内路径(脚本在 game-runtime/scripts/ 下,根 = 上一级)。 const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); const SCHEMA_PATH = join(ROOT, 'schema', 'plugin-manifest.schema.json'); const PLUGINS_DIR = join(ROOT, 'src', 'plugins'); // 读 schema(仅取本校验器需要的字段;schema 是单一事实源,约束改动须同步本校验器)。 const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')); /** * 针对一个对象按(子)schema 做关键约束校验,收集错误到 errs。 * @param {*} value 待校验值 * @param {object} sch (子)schema * @param {string} path 字段路径(错误定位用) * @param {string[]} errs 错误累加数组 */ function validate(value, sch, path, errs) { // type 校验(支持 object/array/string/integer) if (sch.type === 'object') { if (value == null || typeof value !== 'object' || Array.isArray(value)) { errs.push(`${path}: 应为 object`); return; } // required for (const key of sch.required || []) { if (!(key in value)) errs.push(`${path}: 缺少必填字段 "${key}"`); } // additionalProperties:false → 不允许未声明字段 if (sch.additionalProperties === false) { const allowed = new Set(Object.keys(sch.properties || {})); for (const k of Object.keys(value)) { if (!allowed.has(k)) errs.push(`${path}: 出现未声明字段 "${k}"(additionalProperties:false)`); } } // 递归校验各属性 for (const [k, subSch] of Object.entries(sch.properties || {})) { if (k in value) validate(value[k], subSch, path === '$' ? k : `${path}.${k}`, errs); } return; } if (sch.type === 'array') { if (!Array.isArray(value)) { errs.push(`${path}: 应为 array`); return; } if (sch.uniqueItems && new Set(value).size !== value.length) { errs.push(`${path}: 数组元素须唯一(uniqueItems)`); } value.forEach((item, i) => validate(item, sch.items || {}, `${path}[${i}]`, errs)); return; } if (sch.type === 'string') { if (typeof value !== 'string') { errs.push(`${path}: 应为 string`); return; } if (sch.minLength != null && value.length < sch.minLength) errs.push(`${path}: 长度 < ${sch.minLength}`); if (sch.maxLength != null && value.length > sch.maxLength) errs.push(`${path}: 长度 > ${sch.maxLength}`); if (sch.pattern && !new RegExp(sch.pattern).test(value)) { errs.push(`${path}: 不匹配 pattern /${sch.pattern}/(值="${value}")`); } return; } if (sch.type === 'integer') { if (typeof value !== 'number' || !Number.isInteger(value)) { errs.push(`${path}: 应为 integer`); return; } if (sch.minimum != null && value < sch.minimum) errs.push(`${path}: < minimum ${sch.minimum}`); if (sch.maximum != null && value > sch.maximum) errs.push(`${path}: > maximum ${sch.maximum}`); return; } } /** 收集待校验的 manifest 路径:命令行给定优先,否则扫描 src/plugins/<*>/manifest.json。 */ function collectTargets() { const argv = process.argv.slice(2); if (argv.length > 0) return argv.map((p) => resolve(p)); const targets = []; if (existsSync(PLUGINS_DIR)) { for (const name of readdirSync(PLUGINS_DIR)) { const mf = join(PLUGINS_DIR, name, 'manifest.json'); if (existsSync(mf)) targets.push(mf); } } return targets; } // ── 主流程 ────────────────────────────────────────────────────────────────── const targets = collectTargets(); if (targets.length === 0) { console.error('[validate-manifest] 未找到任何 manifest.json(src/plugins/<*>/manifest.json)'); process.exit(1); } let failed = 0; for (const file of targets) { const errs = []; let data; try { data = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { console.error(`✘ ${file}: JSON 解析失败 —— ${e.message}`); failed += 1; continue; } validate(data, schema, '$', errs); if (errs.length === 0) { console.log(`✔ ${file} [${data.name}@${data.version}, gzBudget=${data.gzBudgetBytes}]`); } else { console.error(`✘ ${file}`); for (const e of errs) console.error(` - ${e}`); failed += 1; } } console.log(`\n[validate-manifest] 共 ${targets.length} 件,通过 ${targets.length - failed},失败 ${failed}`); process.exit(failed === 0 ? 0 : 1);