/** * Valibot schema 快照 CI gate(架构红线 §10.2「锁死数据契约」)。 * * 行为三分支(对应任务 §8 测试要求): * - contracts 缺席(无法 import `@drama/contracts` 或其无 `dumpSchemas`)→ 打印跳过日志,退出 0。 * (contracts 未就绪时不阻塞 Wave A 启动。) * - 已提交快照与当前 `dumpSchemas()` 输出一致 → 退出 0。 * - 漂移 → 打印 diff 摘要,退出非 0。 * * 本脚本不持有任何 schema 定义——快照与 `dumpSchemas` 由 `@drama/contracts` 提供。 * 可被单测复用:导出 `runSchemaCheck({ loadSchemas, readSnapshot, logger })`, * CLI 入口在文件末尾用真实 loader 调用它。 * * @typedef {object} SchemaCheckDeps * @property {() => Promise | null>} loadSchemas 取当前 schema 快照对象;contracts 缺席返回 null。 * @property {() => string | null} readSnapshot 读已提交快照文本;不存在返回 null。 * @property {{ log: (msg: string) => void, error: (msg: string) => void }} [logger] * * @typedef {object} SchemaCheckResult * @property {0 | 1} code 退出码。 * @property {'skipped' | 'match' | 'drift' | 'no-snapshot'} status */ import path from 'node:path' import fs from 'node:fs' import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = path.resolve(here, '../..') const SNAPSHOT_PATH = path.join(REPO_ROOT, 'packages/contracts/schema-snapshot.json') /** * 纯函数核心:依赖全注入,便于单测三分支。 * @param {SchemaCheckDeps} deps * @returns {Promise} */ export async function runSchemaCheck(deps) { const logger = deps.logger || { log: console.log, error: console.error } const current = await deps.loadSchemas() if (current == null) { logger.log('[schema:check] @drama/contracts 未就位(缺 dumpSchemas),优雅跳过。') return { code: 0, status: 'skipped' } } const currentText = stableStringify(current) const committed = deps.readSnapshot() if (committed == null) { logger.log( '[schema:check] 未找到已提交快照。首次请将 dumpSchemas() 输出写入 packages/contracts/schema-snapshot.json。', ) return { code: 0, status: 'no-snapshot' } } if (normalize(committed) === normalize(currentText)) { logger.log('[schema:check] schema 快照一致,无漂移。') return { code: 0, status: 'match' } } logger.error('[schema:check] 检测到 schema 漂移:当前 dumpSchemas() 与已提交快照不一致。') logger.error('[schema:check] 若为预期变更,请更新 packages/contracts/schema-snapshot.json 并提交。') return { code: 1, status: 'drift' } } /** * 稳定序列化:键排序,保证快照对键顺序不敏感(避免伪漂移)。 * @param {unknown} value * @returns {string} */ export function stableStringify(value) { return JSON.stringify(sortKeys(value), null, 2) } /** @param {unknown} v @returns {unknown} */ function sortKeys(v) { if (Array.isArray(v)) return v.map(sortKeys) if (v && typeof v === 'object') { /** @type {Record} */ const out = {} for (const k of Object.keys(/** @type {Record} */ (v)).sort()) { out[k] = sortKeys(/** @type {Record} */ (v)[k]) } return out } return v } /** @param {string} text @returns {string} */ function normalize(text) { return text.replace(/\r\n/g, '\n').trim() } /** * 真实 loader:尝试动态 import `@drama/contracts` 的 `dumpSchemas`。 * 任何解析失败(包未发布 / 无导出)都视为「缺席」,返回 null。 * @returns {Promise | null>} */ async function loadContractsSchemas() { try { // @ts-ignore — @drama/contracts 是可选依赖:未就位时 import 抛错并走 catch 返 null(设计如此)。 const mod = await import('@drama/contracts') if (typeof mod.dumpSchemas !== 'function') return null return await mod.dumpSchemas() } catch { try { const mod = await import(pathToFileUrl(path.join(REPO_ROOT, 'packages/contracts/src/index.js'))) if (typeof mod.dumpSchemas !== 'function') return null return await mod.dumpSchemas() } catch { return null } } } /** * @param {string} filePath * @returns {string} */ function pathToFileUrl(filePath) { return new URL(`file://${path.resolve(filePath)}`).href } /** @returns {string | null} */ function readCommittedSnapshot() { try { return fs.readFileSync(SNAPSHOT_PATH, 'utf8') } catch { return null } } // CLI 入口:仅当作为脚本直接运行时执行(被 import 作单测时不触发)。 if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { runSchemaCheck({ loadSchemas: loadContractsSchemas, readSnapshot: readCommittedSnapshot, }).then((r) => { process.exit(r.code) }) }