/** * 契约漂移检查(任务 A1.7 / A1.8)。 * * 五件事: * 1. 事件与 HTTP 样例必须符合 contracts/schemas 下的 JSON Schema * 2. 事件类型在 schema、TS 镜像、协议文档三处必须完全一致 * 3. 每种事件类型、每个 HTTP payload 都要有样例 * 4. 协议文档里的端点表必须与 contracts/protocol.ts 的 ENDPOINT_SPECS 一致 * 5. 文档与常量里提到的类型名必须真的在 protocol.ts 里导出 */ import assert from 'node:assert/strict'; import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import Ajv2020 from 'ajv/dist/2020.js'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const SCHEMA_DIR = join(ROOT, 'contracts', 'schemas'); const SCHEMA_BASE = 'https://aps.local/contracts/'; const EVENTS_SCHEMA_ID = `${SCHEMA_BASE}events.schema.json`; const readText = (rel) => readFile(join(ROOT, rel), 'utf8'); async function buildAjv() { const ajv = new Ajv2020({ allErrors: true, strict: false }); for (const name of await readdir(SCHEMA_DIR)) { if (!name.endsWith('.json')) continue; ajv.addSchema(JSON.parse(await readFile(join(SCHEMA_DIR, name), 'utf8'))); } return ajv; } const formatErrors = (errors) => (errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).join('; '); const eventFixtures = (text) => text.split(/\r?\n/).filter((l) => l.trim()); /** 事件类型:schema 里的 const、TS 里的信封泛型、文档表格首列 */ async function eventTypeSets() { const schemaTypes = new Set( [...(await readText('contracts/schemas/events.schema.json')).matchAll(/"const":\s*"([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)"/g)].map((m) => m[1]), ); const tsTypes = new Set([...(await readText('contracts/events.ts')).matchAll(/EventEnvelope<'([^']+)'/g)].map((m) => m[1])); const docTypes = new Set([...(await readText('docs/spec/protocol.md')).matchAll(/^\| `([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)` \|/gm)].map((m) => m[1])); return { schemaTypes, tsTypes, docTypes }; } const diff = (a, b, label) => [...a].filter((t) => !b.has(t)).map((t) => `${label}:${t}`); const normalizePath = (p) => p.replace(/\$\{[^}]*\}/g, '{}').replace(/\{[^}]*\}/g, '{}'); test('事件样例符合 events.schema.json', async () => { const ajv = await buildAjv(); const validate = ajv.getSchema(EVENTS_SCHEMA_ID); assert.ok(validate, '未注册 events.schema.json'); const lines = eventFixtures(await readText('contracts/fixtures/events.jsonl')); assert.ok(lines.length >= 11, `样例太少:${lines.length}`); for (const line of lines) { const event = JSON.parse(line); assert.ok(validate(event), `${event.type} 样例不符合 schema:${formatErrors(validate.errors)}`); } }); test('HTTP 样例符合各自 schema', async () => { const ajv = await buildAjv(); const cases = JSON.parse(await readText('contracts/fixtures/http.json')); for (const [name, testCase] of Object.entries(cases)) { const validate = ajv.getSchema(SCHEMA_BASE + testCase.schema); assert.ok(validate, `${name}:未注册 ${testCase.schema}`); assert.ok(validate(testCase.value), `${name} 不符合 ${testCase.schema}:${formatErrors(validate.errors)}`); } }); test('事件类型在 schema、TS 与协议文档三处一致', async () => { const { schemaTypes, tsTypes, docTypes } = await eventTypeSets(); assert.ok(schemaTypes.size >= 11, `schema 里事件类型太少:${schemaTypes.size}`); const problems = [ ...diff(schemaTypes, tsTypes, '只在 schema'), ...diff(tsTypes, schemaTypes, '只在 TS'), ...diff(schemaTypes, docTypes, '只在 schema(文档缺)'), ...diff(docTypes, schemaTypes, '只在文档'), ]; assert.deepEqual(problems, [], `事件类型不一致:\n${problems.join('\n')}`); }); test('每种事件类型都有样例', async () => { const { schemaTypes } = await eventTypeSets(); const fixtureTypes = new Set(eventFixtures(await readText('contracts/fixtures/events.jsonl')).map((l) => JSON.parse(l).type)); const missing = [...schemaTypes].filter((t) => !fixtureTypes.has(t)); assert.deepEqual(missing, [], `以下事件类型缺样例:${missing.join('、')}`); }); test('每个 HTTP payload 类型都有样例', async () => { const protocol = await readText('contracts/protocol.ts'); const exported = new Set([...protocol.matchAll(/^export (?:interface|type) (\w+)/gm)].map((m) => m[1])); exported.delete('CapabilityId'); // 类型别名,不是 payload exported.delete('EndpointSpec'); // 端点表结构,不是 payload const cases = JSON.parse(await readText('contracts/fixtures/http.json')); const covered = new Set(Object.values(cases).map((c) => c.type)); const missing = [...exported].filter((t) => !covered.has(t)); const extra = [...covered].filter((t) => !exported.has(t)); assert.deepEqual(missing, [], `以下类型缺样例:${missing.join('、')}`); assert.deepEqual(extra, [], `样例里的类型不在 protocol.ts 导出中:${extra.join('、')}`); }); test('协议文档端点表与 ENDPOINT_SPECS 一致', async () => { const protocol = await readText('contracts/protocol.ts'); const specs = [...protocol.matchAll(/method:\s*'(GET|POST)',\s*path:\s*'([^']+)',\s*request:\s*'([^']*)',\s*response:\s*'([^']*)'/g)].map((m) => ({ method: m[1], path: m[2], request: m[3], response: m[4], })); assert.ok(specs.length >= 12, `ENDPOINT_SPECS 条目太少:${specs.length}`); const doc = await readText('docs/spec/protocol.md'); const rows = [...doc.matchAll(/^\|\s*(GET|POST)\s*\|\s*`([^`]+)`\s*\|([^|]*)\|([^|]*)\|/gm)].map((m) => ({ method: m[1], path: m[2], request: m[3].trim(), response: m[4].trim(), })); assert.ok(rows.length >= 12, `文档端点表条目太少:${rows.length}`); const key = (e) => `${e.method} ${normalizePath(e.path)}`; const specKeys = new Set(specs.map(key)); const docKeys = new Set(rows.map(key)); const problems = [...diff(specKeys, docKeys, '只在 ENDPOINT_SPECS'), ...diff(docKeys, specKeys, '只在文档')]; assert.deepEqual(problems, [], `端点不一致:\n${problems.join('\n')}`); // 文档里用反引号标出的类型名必须真的存在 const exported = new Set([...protocol.matchAll(/^export (?:interface|type) (\w+)/gm)].map((m) => m[1])); const unknown = []; for (const row of rows) { for (const cell of [row.request, row.response]) { const hit = cell.match(/^`([A-Za-z][A-Za-z0-9_]*)`$/); if (hit && !exported.has(hit[1])) unknown.push(`${row.method} ${row.path} → ${hit[1]}`); } } assert.deepEqual(unknown, [], `文档引用了未导出的类型:${unknown.join('、')}`); }); test('ENDPOINTS 常量与 ENDPOINT_SPECS 路径一致', async () => { const protocol = await readText('contracts/protocol.ts'); const block = protocol.match(/export const ENDPOINTS = \{([\s\S]*?)\n\} as const;/); assert.ok(block, '未找到 ENDPOINTS 常量'); const literalPaths = new Set([...block[1].matchAll(/['`](\/v1[^'`]*)['`]/g)].map((m) => normalizePath(m[1]))); const specPaths = new Set([...protocol.matchAll(/path:\s*'([^']+)'/g)].map((m) => normalizePath(m[1]))); const problems = [...diff(literalPaths, specPaths, '只在 ENDPOINTS'), ...diff(specPaths, literalPaths, '只在 ENDPOINT_SPECS')]; assert.deepEqual(problems, [], `路径不一致:\n${problems.join('\n')}`); });