feat: frozen schema + CI validation to prevent client-breaking data

引入 frozen JSON Schema 契约(schemas/)和自动校验,作为已发布客户端的兼容防线。

背景:
PR #1 把 reasoning 模型 defaultTemperature/defaultTopP 写为 null,已发布客户端
schema 严格 number → readComputeConfig 校验失败 → sync 死锁。详见 desirecore PR #471。

本次新增:
- schemas/provider.schema.json: 镜像 desirecore d185299(fix #471 之前)的 strict
  computeProviderSchema/providerModelSchema,禁止 null/string/未知字段
- schemas/{manifest,service-map,pricing,providers-index}.schema.json: 配套契约
- scripts/validate.mjs: 扫所有数据文件自动校验
- __tests__/validate.test.mjs: 28 个测试,含 PR #1 反例的回归测试
- .github/workflows/validate.yml: PR/push 自动跑 validate + test

未来新增字段流程:
1. 先在 desirecore 主仓升级 schema 接受新字段
2. 发布新客户端,等用户升级
3. 再更新本仓库 frozen schema 和数据

否则老客户端因 additionalProperties: false 拒绝未知字段而死锁。
This commit is contained in:
2026-04-25 21:09:23 +08:00
parent 10465e3570
commit bd448f6c43
11 changed files with 902 additions and 0 deletions

154
scripts/validate.mjs Normal file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* 校验 config-center 所有数据文件是否符合 frozen schema 契约。
*
* 用法:
* node scripts/validate.mjs # 校验整个仓库
* node scripts/validate.mjs --file <p> # 校验单个文件
*
* 退出码0 = 全部通过1 = 至少一个文件校验失败。
*
* 设计:
* - frozen schema 镜像 desirecore d185299fix #471 之前)的严格 schema
* - 任何破坏老版本兼容的数据(如 defaultTemperature: null会被拒绝
* - CI on pull_request 自动跑此脚本,不通过则禁止合并
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, dirname, basename, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Ajv } from 'ajv'
import addFormats from 'ajv-formats'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
/** 加载 schemas/ 下所有 JSON Schema。每次调用使用独立的 Ajv 实例避免 $id 冲突。 */
export function loadSchemas() {
const ajv = new Ajv({ allErrors: true, strict: false })
addFormats(ajv)
const schemasDir = join(ROOT, 'schemas')
const map = {}
for (const file of readdirSync(schemasDir)) {
if (!file.endsWith('.schema.json')) continue
const schema = JSON.parse(readFileSync(join(schemasDir, file), 'utf8'))
map[basename(file, '.schema.json')] = ajv.compile(schema)
}
return map
}
/**
* 决定单个数据文件应使用哪个 schema。
* 返回 schema key 字符串,或 null 表示无需校验。
*/
function pickSchemaKey(absPath) {
const rel = relative(ROOT, absPath).replaceAll('\\', '/')
if (rel === 'manifest.json') return 'manifest'
if (rel === 'compute/pricing.json') return 'pricing'
if (rel === 'compute/service-map.json') return 'service-map'
if (rel === 'compute/providers/_index.json') return 'providers-index'
if (rel === 'compute/coding-plans/_index.json') return 'providers-index'
if (rel.startsWith('compute/providers/') && rel.endsWith('.json')) return 'provider'
if (rel.startsWith('compute/coding-plans/') && rel.endsWith('.json')) return 'provider'
return null
}
const SKIP_DIRS = new Set(['node_modules', 'schemas', '__tests__', 'scripts'])
function* walkJsonFiles(dir) {
for (const entry of readdirSync(dir)) {
if (entry.startsWith('.') || SKIP_DIRS.has(entry)) continue
const full = join(dir, entry)
const st = statSync(full)
if (st.isDirectory()) {
yield* walkJsonFiles(full)
} else if (entry.endsWith('.json')) {
yield full
}
}
}
function formatErrors(errors) {
return errors
.map((e) => ` ${e.instancePath || '/'}: ${e.message}${
e.params && Object.keys(e.params).length ? ` (${JSON.stringify(e.params)})` : ''
}`)
.join('\n')
}
/** 校验单个文件,返回 { ok, schemaKey, errors? } */
export function validateFile(absPath, validators = loadSchemas()) {
const schemaKey = pickSchemaKey(absPath)
if (!schemaKey) return { ok: true, schemaKey: null, skipped: true }
const validator = validators[schemaKey]
if (!validator) {
return { ok: false, schemaKey, errors: [{ message: `schema '${schemaKey}' not found` }] }
}
let data
try {
data = JSON.parse(readFileSync(absPath, 'utf8'))
} catch (err) {
return { ok: false, schemaKey, errors: [{ message: `JSON parse error: ${err.message}` }] }
}
const valid = validator(data)
if (valid) return { ok: true, schemaKey }
return { ok: false, schemaKey, errors: validator.errors }
}
function main() {
const args = process.argv.slice(2)
const fileArgIdx = args.indexOf('--file')
const targets = []
if (fileArgIdx >= 0) {
const file = args[fileArgIdx + 1]
if (!file) {
console.error('--file requires a path argument')
process.exit(2)
}
targets.push(resolve(file))
} else {
targets.push(...walkJsonFiles(ROOT))
}
const validators = loadSchemas()
let failures = 0
let validated = 0
let skipped = 0
for (const file of targets) {
const rel = relative(ROOT, file)
const result = validateFile(file, validators)
if (result.skipped) {
skipped++
continue
}
if (result.ok) {
validated++
console.log(` ok [${result.schemaKey}] ${rel}`)
} else {
failures++
console.error(` fail [${result.schemaKey}] ${rel}`)
console.error(formatErrors(result.errors))
}
}
console.log()
console.log(`Summary: ${validated} validated, ${skipped} skipped, ${failures} failed`)
if (failures > 0) {
console.error('\n校验失败请检查上方错误。')
console.error('常见问题:')
console.error(' - defaultTemperature/defaultTopP 必须是 number禁止写 nullreasoning 模型应完全省略字段)')
console.error(' - additionalProperties 严格模式:新增字段需先在 desirecore 主仓升级 schema 再推送数据')
process.exit(1)
}
}
const isMain = process.argv[1] === fileURLToPath(import.meta.url)
if (isMain) {
main()
}