schema-check.js
4.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
/**
* 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)
})
}