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

300
__tests__/validate.test.mjs Normal file
View File

@@ -0,0 +1,300 @@
/**
* config-center frozen schema 校验测试
*
* 关键场景:
* - 现有所有数据文件必须通过 frozen schema 校验
* - PR #1 引发死锁的反例defaultTemperature: null必须被拒绝
* - 防御未知字段additionalProperties: false保护老客户端
*
* 运行:
* npm test
* node --test __tests__/
*/
import { describe, it } from 'node:test'
import { strict as assert } from 'node:assert'
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Ajv } from 'ajv'
import addFormats from 'ajv-formats'
import { validateFile, loadSchemas } from '../scripts/validate.mjs'
const validators = loadSchemas()
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
function loadSchema(name) {
const p = join(ROOT, 'schemas', `${name}.schema.json`)
return JSON.parse(readFileSync(p, 'utf8'))
}
function compile(name) {
const ajv = new Ajv({ allErrors: true, strict: false })
addFormats(ajv)
return ajv.compile(loadSchema(name))
}
// ==================== 真实数据 happy path ====================
describe('真实数据全量校验', () => {
it('所有 compute/providers/*.json 应通过 provider schema', () => {
const dir = join(ROOT, 'compute', 'providers')
const failures = []
for (const file of readdirSync(dir)) {
if (!file.endsWith('.json') || file === '_index.json') continue
const result = validateFile(join(dir, file), validators)
if (!result.ok) failures.push({ file, errors: result.errors })
}
assert.equal(
failures.length,
0,
`${failures.length} provider 文件校验失败:\n${JSON.stringify(failures, null, 2)}`,
)
})
it('所有 compute/coding-plans/*.json 应通过 provider schema', () => {
const dir = join(ROOT, 'compute', 'coding-plans')
const failures = []
for (const file of readdirSync(dir)) {
if (!file.endsWith('.json') || file === '_index.json') continue
const result = validateFile(join(dir, file), validators)
if (!result.ok) failures.push({ file, errors: result.errors })
}
assert.equal(
failures.length,
0,
`${failures.length} coding-plan 文件校验失败:\n${JSON.stringify(failures, null, 2)}`,
)
})
it('manifest.json 应通过 manifest schema', () => {
const result = validateFile(join(ROOT, 'manifest.json'), validators)
assert.equal(result.ok, true, JSON.stringify(result.errors, null, 2))
})
it('compute/pricing.json 应通过 pricing schema', () => {
const result = validateFile(join(ROOT, 'compute', 'pricing.json'), validators)
assert.equal(result.ok, true, JSON.stringify(result.errors, null, 2))
})
it('compute/service-map.json 应通过 service-map schema', () => {
const result = validateFile(join(ROOT, 'compute', 'service-map.json'), validators)
assert.equal(result.ok, true, JSON.stringify(result.errors, null, 2))
})
it('两个 _index.json 应通过 providers-index schema', () => {
const r1 = validateFile(join(ROOT, 'compute', 'providers', '_index.json'), validators)
const r2 = validateFile(join(ROOT, 'compute', 'coding-plans', '_index.json'), validators)
assert.equal(r1.ok, true, JSON.stringify(r1.errors, null, 2))
assert.equal(r2.ok, true, JSON.stringify(r2.errors, null, 2))
})
})
// ==================== Provider schema 反例 ====================
describe('provider schema 反例(防 PR #1 重演)', () => {
const validate = compile('provider')
function makeValidProvider() {
return {
id: 'provider-test-001',
provider: 'test',
label: 'Test',
baseUrl: 'https://api.test.com',
apiKeyRef: 'test',
apiKeyVerified: false,
enabled: false,
status: 'unconfigured',
services: ['chat'],
models: [
{
modelName: 'm1',
displayName: 'M1',
serviceType: ['chat'],
capabilities: ['chat'],
},
],
}
}
it('合法 minimal provider 通过', () => {
assert.equal(validate(makeValidProvider()), true, JSON.stringify(validate.errors))
})
it('拒绝 defaultTemperature: nullPR #1 死锁元凶)', () => {
const data = makeValidProvider()
data.models[0].defaultTemperature = null
assert.equal(validate(data), false)
assert.ok(
validate.errors.some((e) => e.instancePath.endsWith('/defaultTemperature')),
`应有 defaultTemperature 错误,实际:${JSON.stringify(validate.errors)}`,
)
})
it('拒绝 defaultTopP: null', () => {
const data = makeValidProvider()
data.models[0].defaultTopP = null
assert.equal(validate(data), false)
assert.ok(validate.errors.some((e) => e.instancePath.endsWith('/defaultTopP')))
})
it('拒绝 defaultTemperature: "0.7"string', () => {
const data = makeValidProvider()
data.models[0].defaultTemperature = '0.7'
assert.equal(validate(data), false)
})
it('接受省略 defaultTemperature/defaultTopPreasoning 模型推荐做法)', () => {
const data = makeValidProvider()
// 不设这两个字段
assert.equal(validate(data), true, JSON.stringify(validate.errors))
})
it('接受 defaultTemperature: 0.7(合法 number', () => {
const data = makeValidProvider()
data.models[0].defaultTemperature = 0.7
data.models[0].defaultTopP = 0.95
assert.equal(validate(data), true, JSON.stringify(validate.errors))
})
it('拒绝 defaultTemperature 超出 [0, 2] 范围', () => {
const data = makeValidProvider()
data.models[0].defaultTemperature = 3.0
assert.equal(validate(data), false)
})
it('拒绝 model 顶层未知字段additionalProperties: false 保护老客户端)', () => {
const data = makeValidProvider()
data.models[0].brandNewField = 'oops'
assert.equal(validate(data), false)
assert.ok(
validate.errors.some((e) => e.keyword === 'additionalProperties'),
`应有 additionalProperties 错误,实际:${JSON.stringify(validate.errors)}`,
)
})
it('拒绝 provider 顶层未知字段', () => {
const data = makeValidProvider()
data.unknownTopLevelField = 'oops'
assert.equal(validate(data), false)
assert.ok(validate.errors.some((e) => e.keyword === 'additionalProperties'))
})
it('拒绝缺少必填字段(如 model 缺 modelName', () => {
const data = makeValidProvider()
delete data.models[0].modelName
assert.equal(validate(data), false)
})
it('拒绝非法 status enum', () => {
const data = makeValidProvider()
data.status = 'wrong-status'
assert.equal(validate(data), false)
})
it('拒绝非法 priceCurrency enum仅允许 USD/CNY', () => {
const data = makeValidProvider()
data.priceCurrency = 'EUR'
assert.equal(validate(data), false)
})
})
// ==================== Manifest schema ====================
describe('manifest schema', () => {
const validate = compile('manifest')
it('合法 manifest 通过', () => {
assert.equal(
validate({
version: '1.0.0',
presetDataVersion: 30,
updatedAt: '2026-04-25',
description: 'test',
}),
true,
)
})
it('拒绝 presetDataVersion 为 string', () => {
assert.equal(
validate({
version: '1.0.0',
presetDataVersion: '30',
updatedAt: '2026-04-25',
}),
false,
)
})
it('拒绝非法 updatedAt 格式', () => {
assert.equal(
validate({
version: '1.0.0',
presetDataVersion: 30,
updatedAt: '04/25/2026',
}),
false,
)
})
it('拒绝缺少 presetDataVersion', () => {
assert.equal(
validate({ version: '1.0.0', updatedAt: '2026-04-25' }),
false,
)
})
})
// ==================== Service map schema ====================
describe('service-map schema', () => {
const validate = compile('service-map')
it('合法 service-map 通过', () => {
assert.equal(
validate({
chat: { modelName: 'gpt-5-mini', providerId: 'provider-openai-001' },
}),
true,
)
})
it('拒绝缺少 providerId 的条目', () => {
assert.equal(validate({ chat: { modelName: 'gpt-5-mini' } }), false)
})
it('拒绝条目中含未知字段', () => {
assert.equal(
validate({
chat: {
modelName: 'gpt-5-mini',
providerId: 'provider-openai-001',
extra: 'oops',
},
}),
false,
)
})
})
// ==================== Providers-index schema ====================
describe('providers-index schema', () => {
const validate = compile('providers-index')
it('合法 _index.json 通过', () => {
assert.equal(validate({ description: 'x', order: ['openai', 'anthropic'] }), true)
})
it('拒绝 order 含重复元素', () => {
assert.equal(validate({ order: ['openai', 'openai'] }), false)
})
it('拒绝缺少 order', () => {
assert.equal(validate({ description: 'x' }), false)
})
})