2026-09-23 20:00:47 +08:00
|
|
|
|
/**
|
2026-09-23 20:52:24 +08:00
|
|
|
|
* 契约漂移检查(任务 A1.7 / A1.8)。
|
2026-09-23 20:00:47 +08:00
|
|
|
|
*
|
2026-09-23 20:52:24 +08:00
|
|
|
|
* 五件事:
|
2026-09-23 20:00:47 +08:00
|
|
|
|
* 1. 事件与 HTTP 样例必须符合 contracts/schemas 下的 JSON Schema
|
|
|
|
|
|
* 2. 事件类型在 schema、TS 镜像、协议文档三处必须完全一致
|
2026-09-23 20:52:24 +08:00
|
|
|
|
* 3. 每种事件类型、每个 HTTP payload 都要有样例
|
|
|
|
|
|
* 4. 协议文档里的端点表必须与 contracts/protocol.ts 的 ENDPOINT_SPECS 一致
|
|
|
|
|
|
* 5. 文档与常量里提到的类型名必须真的在 protocol.ts 里导出
|
2026-09-23 20:00:47 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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');
|
2026-09-23 20:52:24 +08:00
|
|
|
|
const SCHEMA_BASE = 'https://aps.local/contracts/';
|
|
|
|
|
|
const EVENTS_SCHEMA_ID = `${SCHEMA_BASE}events.schema.json`;
|
2026-09-23 20:00:47 +08:00
|
|
|
|
|
|
|
|
|
|
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('; ');
|
2026-09-23 20:52:24 +08:00
|
|
|
|
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, '{}');
|
2026-09-23 20:00:47 +08:00
|
|
|
|
|
|
|
|
|
|
test('事件样例符合 events.schema.json', async () => {
|
|
|
|
|
|
const ajv = await buildAjv();
|
|
|
|
|
|
const validate = ajv.getSchema(EVENTS_SCHEMA_ID);
|
|
|
|
|
|
assert.ok(validate, '未注册 events.schema.json');
|
2026-09-23 20:52:24 +08:00
|
|
|
|
const lines = eventFixtures(await readText('contracts/fixtures/events.jsonl'));
|
2026-09-23 20:00:47 +08:00
|
|
|
|
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)) {
|
2026-09-23 20:52:24 +08:00
|
|
|
|
const validate = ajv.getSchema(SCHEMA_BASE + testCase.schema);
|
2026-09-23 20:00:47 +08:00
|
|
|
|
assert.ok(validate, `${name}:未注册 ${testCase.schema}`);
|
2026-09-23 20:52:24 +08:00
|
|
|
|
assert.ok(validate(testCase.value), `${name} 不符合 ${testCase.schema}:${formatErrors(validate.errors)}`);
|
2026-09-23 20:00:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('事件类型在 schema、TS 与协议文档三处一致', async () => {
|
2026-09-23 20:52:24 +08:00
|
|
|
|
const { schemaTypes, tsTypes, docTypes } = await eventTypeSets();
|
2026-09-23 20:00:47 +08:00
|
|
|
|
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 () => {
|
2026-09-23 20:52:24 +08:00
|
|
|
|
const { schemaTypes } = await eventTypeSets();
|
|
|
|
|
|
const fixtureTypes = new Set(eventFixtures(await readText('contracts/fixtures/events.jsonl')).map((l) => JSON.parse(l).type));
|
2026-09-23 20:00:47 +08:00
|
|
|
|
const missing = [...schemaTypes].filter((t) => !fixtureTypes.has(t));
|
|
|
|
|
|
assert.deepEqual(missing, [], `以下事件类型缺样例:${missing.join('、')}`);
|
2026-09-23 20:52:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
2026-09-30 14:26:35 +08:00
|
|
|
|
const specs = [...protocol.matchAll(/method:\s*'(GET|POST|PUT|PATCH|DELETE)',\s*path:\s*'([^']+)',\s*request:\s*'([^']*)',\s*response:\s*'([^']*)'/g)].map((m) => ({
|
2026-09-23 20:52:24 +08:00
|
|
|
|
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');
|
2026-09-30 14:26:35 +08:00
|
|
|
|
const rows = [...doc.matchAll(/^\|\s*(GET|POST|PUT|PATCH|DELETE)\s*\|\s*`([^`]+)`\s*\|([^|]*)\|([^|]*)\|/gm)].map((m) => ({
|
2026-09-23 20:52:24 +08:00
|
|
|
|
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')}`);
|
2026-09-23 20:00:47 +08:00
|
|
|
|
});
|