feat(contracts): 补齐 HTTP 契约与端点一致性检查(A1.8)

新增 http.schema.json(14 个 HTTP payload:健康检查、身份、项目、能力清单、消息、会话 CRUD、发消息、取消、审批响应);receipt/capability/session/approval/error 五份改成 \ 形式以便跨文件引用。样例从 5 条补到 19 条,覆盖 protocol.ts 除 CapabilityId 外的全部导出类型。新增三条测试:HTTP payload 覆盖率、协议文档端点表 vs ENDPOINT_SPECS、ENDPOINTS 常量 vs ENDPOINT_SPECS 路径。反向验证过:把文档里的 /v1/me 改成 /v1/whoami 会被拦下。
This commit is contained in:
zh 2026-09-23 20:52:24 +08:00
parent 12ff5004d7
commit b56e6f5551
10 changed files with 351 additions and 105 deletions

View File

@ -1,29 +1,21 @@
{
"session": {
"schema": "session.schema.json",
"value": { "sessionId": "s-1", "projectId": "p-1", "scope": "project", "status": "active", "title": "十月排产核对", "createdAt": 1757404800000 }
},
"capability": {
"schema": "capability.schema.json",
"value": {
"id": "aps.schedule.preview",
"providerId": "aps",
"title": "试排一版",
"purpose": "按当前项目数据试排一版方案,只出结果不发布",
"effect": "read",
"synonyms": ["试排", "跑一版", "预排"]
}
},
"receipt": {
"schema": "receipt.schema.json",
"value": { "executionId": "exec-1", "status": "pending-confirmation", "approvalId": "apr-1" }
},
"approvalDecision": {
"schema": "approval.schema.json",
"value": { "decision": "approve", "revision": 37, "idempotencyKey": "idem-2f8a1c9d", "note": "按会议结论发布" }
},
"error": {
"schema": "error.schema.json",
"value": { "error": { "code": "auth.expired", "message": "登录态已过期", "retryable": false } }
}
"HealthResponse": { "type": "HealthResponse", "schema": "http.schema.json#/$defs/HealthResponse", "value": { "status": "ok", "version": "0.1.0", "protocolVersion": 1 } },
"MeResponse": { "type": "MeResponse", "schema": "http.schema.json#/$defs/MeResponse", "value": { "userId": "u-1001", "tenantId": "kangni", "displayName": "张工", "clientKind": "desktop" } },
"ProjectSummary": { "type": "ProjectSummary", "schema": "http.schema.json#/$defs/ProjectSummary", "value": { "projectId": "p-1", "name": "康尼十月排产", "status": "active", "writable": true } },
"ProjectListResponse": { "type": "ProjectListResponse", "schema": "http.schema.json#/$defs/ProjectListResponse", "value": { "projects": [{ "projectId": "p-1", "name": "康尼十月排产", "status": "active", "writable": true }] } },
"CapabilitySummary": { "type": "CapabilitySummary", "schema": "capability.schema.json", "value": { "id": "aps.schedule.preview", "providerId": "aps", "title": "试排一版", "purpose": "按当前项目数据试排一版方案,只出结果不发布", "effect": "read", "synonyms": ["试排", "跑一版", "预排"] } },
"CapabilitiesResponse": { "type": "CapabilitiesResponse", "schema": "http.schema.json#/$defs/CapabilitiesResponse", "value": { "capabilities": [{ "id": "aps.schedule.preview", "providerId": "aps", "title": "试排一版", "purpose": "按当前项目数据试排一版方案,只出结果不发布", "effect": "read" }] } },
"SessionSummary": { "type": "SessionSummary", "schema": "session.schema.json", "value": { "sessionId": "s-1", "projectId": "p-1", "scope": "project", "status": "active", "title": "十月排产核对", "createdAt": 1757404800000 } },
"ChatMessage": { "type": "ChatMessage", "schema": "http.schema.json#/$defs/ChatMessage", "value": { "messageId": "m-1", "role": "assistant", "ts": 1757404800000, "text": "正在核算产能" } },
"CreateSessionRequest": { "type": "CreateSessionRequest", "schema": "http.schema.json#/$defs/CreateSessionRequest", "value": { "projectId": "p-1", "scope": "project", "title": "十月排产核对" } },
"CreateSessionResponse": { "type": "CreateSessionResponse", "schema": "http.schema.json#/$defs/CreateSessionResponse", "value": { "session": { "sessionId": "s-1", "projectId": "p-1", "scope": "project", "status": "active", "title": "十月排产核对", "createdAt": 1757404800000 } } },
"SessionListResponse": { "type": "SessionListResponse", "schema": "http.schema.json#/$defs/SessionListResponse", "value": { "sessions": [{ "sessionId": "s-1", "projectId": "p-1", "scope": "project", "status": "active", "title": "十月排产核对", "createdAt": 1757404800000 }] } },
"SessionDetailResponse": { "type": "SessionDetailResponse", "schema": "http.schema.json#/$defs/SessionDetailResponse", "value": { "session": { "sessionId": "s-1", "projectId": "p-1", "scope": "project", "status": "active", "title": "十月排产核对", "createdAt": 1757404800000 }, "messages": [{ "messageId": "m-1", "role": "user", "ts": 1757404800000, "text": "试排一版看看" }] } },
"SendMessageRequest": { "type": "SendMessageRequest", "schema": "http.schema.json#/$defs/SendMessageRequest", "value": { "text": "试排一版看看", "idempotencyKey": "idem-2f8a1c9d" } },
"SendMessageResponse": { "type": "SendMessageResponse", "schema": "http.schema.json#/$defs/SendMessageResponse", "value": { "accepted": true, "messageId": "m-1", "runId": "r-1" } },
"CancelResponse": { "type": "CancelResponse", "schema": "http.schema.json#/$defs/CancelResponse", "value": { "state": "cancelling" } },
"ExecutionReceipt": { "type": "ExecutionReceipt", "schema": "receipt.schema.json", "value": { "executionId": "exec-1", "status": "pending-confirmation", "approvalId": "apr-1" } },
"ApprovalDecisionRequest": { "type": "ApprovalDecisionRequest", "schema": "approval.schema.json", "value": { "decision": "approve", "revision": 37, "idempotencyKey": "idem-2f8a1c9d", "note": "按会议结论发布" } },
"ApprovalDecisionResponse": { "type": "ApprovalDecisionResponse", "schema": "http.schema.json#/$defs/ApprovalDecisionResponse", "value": { "approvalId": "apr-1", "decision": "approved", "receipt": { "executionId": "exec-1", "status": "succeeded" } } },
"ErrorEnvelope": { "type": "ErrorEnvelope", "schema": "error.schema.json", "value": { "error": { "code": "auth.expired", "message": "登录态已过期", "retryable": false } } }
}

View File

@ -23,6 +23,33 @@ export const ENDPOINTS = {
approval: (approvalId: string) => `/v1/approvals/${approvalId}`,
} as const;
/** 端点规格:给实现与文档比对用的机器可读表(方法、路径、请求体、响应体) */
export interface EndpointSpec {
method: 'GET' | 'POST';
/** 路径;路径参数用 {name} 占位 */
path: string;
/** 请求体类型名;没有请求体写 '—' */
request: string;
/** 响应体类型名;事件流写 'SSE' */
response: string;
summary: string;
}
export const ENDPOINT_SPECS: readonly EndpointSpec[] = [
{ method: 'GET', path: '/v1/health', request: '—', response: 'HealthResponse', summary: '健康检查' },
{ method: 'GET', path: '/v1/me', request: '—', response: 'MeResponse', summary: '当前身份与客户端形态' },
{ method: 'GET', path: '/v1/projects', request: '—', response: 'ProjectListResponse', summary: '可见项目' },
{ method: 'GET', path: '/v1/sessions', request: '—', response: 'SessionListResponse', summary: '会话列表' },
{ method: 'POST', path: '/v1/sessions', request: 'CreateSessionRequest', response: 'CreateSessionResponse', summary: '建会话' },
{ method: 'GET', path: '/v1/sessions/{id}', request: '—', response: 'SessionDetailResponse', summary: '会话详情与最近消息' },
{ method: 'GET', path: '/v1/sessions/{id}/events', request: '—', response: 'SSE', summary: '回放 + 实时事件流' },
{ method: 'POST', path: '/v1/sessions/{id}/messages', request: 'SendMessageRequest', response: 'SendMessageResponse', summary: '发消息' },
{ method: 'POST', path: '/v1/sessions/{id}/cancel', request: '—', response: 'CancelResponse', summary: '取消当前运行' },
{ method: 'GET', path: '/v1/capabilities', request: '—', response: 'CapabilitiesResponse', summary: '按身份过滤的能力清单' },
{ method: 'GET', path: '/v1/executions/{executionId}', request: '—', response: 'ExecutionReceipt', summary: '执行状态与回执' },
{ method: 'POST', path: '/v1/approvals/{approvalId}', request: 'ApprovalDecisionRequest', response: 'ApprovalDecisionResponse', summary: '确认卡响应' },
];
/** 能力标识,形如 `aps.schedule.preview` */
export type CapabilityId = string;

View File

@ -3,6 +3,9 @@
"$id": "https://aps.local/contracts/approval.schema.json",
"title": "ApprovalDecisionRequest",
"description": "客户端对确认卡的响应(架构设计 4.2:确认卡防的是误解意图,不是防越权)。",
"$ref": "#/$defs/ApprovalDecisionRequest",
"$defs": {
"ApprovalDecisionRequest": {
"type": "object",
"required": ["decision", "revision", "idempotencyKey"],
"properties": {
@ -13,3 +16,5 @@
},
"additionalProperties": false
}
}
}

View File

@ -3,6 +3,9 @@
"$id": "https://aps.local/contracts/capability.schema.json",
"title": "CapabilitySummary",
"description": "按身份过滤后交给客户端与引擎的能力条目。描述七要素见 docs/spec/capability-description.md。",
"$ref": "#/$defs/CapabilitySummary",
"$defs": {
"CapabilitySummary": {
"type": "object",
"required": ["id", "providerId", "title", "purpose", "effect"],
"properties": {
@ -16,3 +19,5 @@
},
"additionalProperties": false
}
}
}

View File

@ -3,6 +3,9 @@
"$id": "https://aps.local/contracts/error.schema.json",
"title": "ErrorEnvelope",
"description": "HTTP 错误统一形状;SSE 里的错误见 events.schema.json 的 error 事件。",
"$ref": "#/$defs/ErrorEnvelope",
"$defs": {
"ErrorEnvelope": {
"type": "object",
"required": ["error"],
"properties": {
@ -20,3 +23,5 @@
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,139 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aps.local/contracts/http.schema.json",
"title": "HTTP payloads",
"description": "HTTP 请求体与响应体的形状。端点、方法与状态码见 docs/spec/protocol.md;本文件只定义数据形状。带自己文件的类型(会话、能力、回执、审批请求、错误)不在这里重复。",
"$defs": {
"HealthResponse": {
"type": "object",
"required": ["status", "version", "protocolVersion"],
"properties": {
"status": { "const": "ok" },
"version": { "type": "string" },
"protocolVersion": { "type": "integer" }
},
"additionalProperties": false
},
"MeResponse": {
"type": "object",
"required": ["userId", "tenantId", "displayName", "clientKind"],
"properties": {
"userId": { "type": "string" },
"tenantId": { "type": "string" },
"displayName": { "type": "string" },
"clientKind": { "type": "string", "enum": ["desktop", "browser"] }
},
"additionalProperties": false
},
"ProjectSummary": {
"type": "object",
"required": ["projectId", "name", "status", "writable"],
"properties": {
"projectId": { "type": "string" },
"name": { "type": "string" },
"status": { "type": "string", "enum": ["active", "archived"] },
"writable": { "type": "boolean", "description": "项目内可写;个人会话只读(ADR-0003)" }
},
"additionalProperties": false
},
"ProjectListResponse": {
"type": "object",
"required": ["projects"],
"properties": {
"projects": { "type": "array", "items": { "$ref": "#/$defs/ProjectSummary" } }
},
"additionalProperties": false
},
"CapabilitiesResponse": {
"type": "object",
"required": ["capabilities"],
"properties": {
"capabilities": { "type": "array", "items": { "$ref": "capability.schema.json#/$defs/CapabilitySummary" } }
},
"additionalProperties": false
},
"ChatMessage": {
"type": "object",
"required": ["messageId", "role", "ts", "text"],
"properties": {
"messageId": { "type": "string" },
"role": { "type": "string", "enum": ["user", "assistant", "system"] },
"ts": { "type": "integer" },
"text": { "type": "string" }
},
"additionalProperties": false
},
"CreateSessionRequest": {
"type": "object",
"required": ["scope"],
"properties": {
"projectId": { "type": "string" },
"scope": { "type": "string", "enum": ["personal", "project"] },
"title": { "type": "string" }
},
"additionalProperties": false
},
"CreateSessionResponse": {
"type": "object",
"required": ["session"],
"properties": {
"session": { "$ref": "session.schema.json#/$defs/SessionSummary" }
},
"additionalProperties": false
},
"SessionListResponse": {
"type": "object",
"required": ["sessions"],
"properties": {
"sessions": { "type": "array", "items": { "$ref": "session.schema.json#/$defs/SessionSummary" } }
},
"additionalProperties": false
},
"SessionDetailResponse": {
"type": "object",
"required": ["session", "messages"],
"properties": {
"session": { "$ref": "session.schema.json#/$defs/SessionSummary" },
"messages": { "type": "array", "items": { "$ref": "#/$defs/ChatMessage" } }
},
"additionalProperties": false
},
"SendMessageRequest": {
"type": "object",
"required": ["text", "idempotencyKey"],
"properties": {
"text": { "type": "string", "minLength": 1 },
"idempotencyKey": { "type": "string", "minLength": 8 }
},
"additionalProperties": false
},
"SendMessageResponse": {
"type": "object",
"required": ["accepted", "messageId", "runId"],
"properties": {
"accepted": { "const": true },
"messageId": { "type": "string" },
"runId": { "type": "string" }
},
"additionalProperties": false
},
"CancelResponse": {
"type": "object",
"required": ["state"],
"properties": {
"state": { "type": "string", "enum": ["cancelling", "idle"] }
},
"additionalProperties": false
},
"ApprovalDecisionResponse": {
"type": "object",
"required": ["approvalId", "decision"],
"properties": {
"approvalId": { "type": "string" },
"decision": { "type": "string", "enum": ["approved", "rejected", "expired"] },
"receipt": { "$ref": "receipt.schema.json#/$defs/ExecutionReceipt" }
},
"additionalProperties": false
}
}
}

View File

@ -3,6 +3,9 @@
"$id": "https://aps.local/contracts/receipt.schema.json",
"title": "ExecutionReceipt",
"description": "能力调用的统一回执(架构设计 4.2)。",
"$ref": "#/$defs/ExecutionReceipt",
"$defs": {
"ExecutionReceipt": {
"type": "object",
"required": ["executionId", "status"],
"properties": {
@ -14,3 +17,5 @@
},
"additionalProperties": false
}
}
}

View File

@ -3,6 +3,9 @@
"$id": "https://aps.local/contracts/session.schema.json",
"title": "SessionSummary",
"description": "会话概要(ADR-0003:个人会话只读,写操作必须在项目会话内)。",
"$ref": "#/$defs/SessionSummary",
"$defs": {
"SessionSummary": {
"type": "object",
"required": ["sessionId", "scope", "status", "title", "createdAt"],
"properties": {
@ -15,3 +18,5 @@
},
"additionalProperties": false
}
}
}

View File

@ -103,5 +103,5 @@
改协议必须同时改三处,否则 `pnpm test` 会失败:
1. `contracts/schemas/*.json`(规范本体)
2. `contracts/events.ts` / `contracts/protocol.ts`(TS 镜像)
2. `contracts/events.ts` / `contracts/protocol.ts`(TS 镜像;第 4 节的端点表与 `ENDPOINT_SPECS` 由测试逐条比对)
3. 本文档(第 4、6 节的表)

View File

@ -1,10 +1,12 @@
/**
* 契约漂移检查(任务 A1.7)。
* 契约漂移检查(任务 A1.7 / A1.8)。
*
* 三件事:
* 五件事:
* 1. 事件与 HTTP 样例必须符合 contracts/schemas 下的 JSON Schema
* 2. 事件类型在 schema、TS 镜像、协议文档三处必须完全一致
* 3. 每种事件类型都要有样例,避免新增类型忘了配样例
* 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';
@ -16,7 +18,8 @@ import Ajv2020 from 'ajv/dist/2020.js';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const SCHEMA_DIR = join(ROOT, 'contracts', 'schemas');
const EVENTS_SCHEMA_ID = 'https://aps.local/contracts/events.schema.json';
const SCHEMA_BASE = 'https://aps.local/contracts/';
const EVENTS_SCHEMA_ID = `${SCHEMA_BASE}events.schema.json`;
const readText = (rel) => readFile(join(ROOT, rel), 'utf8');
@ -30,12 +33,26 @@ async function buildAjv() {
}
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 = (await readText('contracts/fixtures/events.jsonl')).split(/\r?\n/).filter((l) => l.trim());
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);
@ -47,22 +64,15 @@ 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 schemaId = `https://aps.local/contracts/${testCase.schema}`;
const validate = ajv.getSchema(schemaId);
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)}`);
assert.ok(validate(testCase.value), `${name} 不符合 ${testCase.schema}:${formatErrors(validate.errors)}`);
}
});
test('事件类型在 schema、TS 与协议文档三处一致', async () => {
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]));
const { schemaTypes, tsTypes, docTypes } = await eventTypeSets();
assert.ok(schemaTypes.size >= 11, `schema 里事件类型太少:${schemaTypes.size}`);
const diff = (a, b, label) => [...a].filter((t) => !b.has(t)).map((t) => `${label}:${t}`);
const problems = [
...diff(schemaTypes, tsTypes, '只在 schema'),
...diff(tsTypes, schemaTypes, '只在 TS'),
@ -73,15 +83,68 @@ test('事件类型在 schema、TS 与协议文档三处一致', async () => {
});
test('每种事件类型都有样例', async () => {
const fixtureTypes = new Set(
(await readText('contracts/fixtures/events.jsonl'))
.split(/\r?\n/)
.filter((l) => l.trim())
.map((l) => JSON.parse(l).type),
);
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 { 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')}`);
});