schema-check.js 4.88 KB
/**
 * 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<Record<string, unknown> | 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<SchemaCheckResult>}
 */
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<string, unknown>} */
    const out = {}
    for (const k of Object.keys(/** @type {Record<string, unknown>} */ (v)).sort()) {
      out[k] = sortKeys(/** @type {Record<string, unknown>} */ (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<Record<string, unknown> | 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)
  })
}