From b56e6f5551661d562a6690ab70278f4ccb4ba9be Mon Sep 17 00:00:00 2001 From: zh Date: Wed, 23 Sep 2026 20:52:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(contracts):=20=E8=A1=A5=E9=BD=90=20HTTP=20?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=8E=E7=AB=AF=E7=82=B9=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E6=A3=80=E6=9F=A5=EF=BC=88A1.8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 会被拦下。 --- contracts/fixtures/http.json | 46 ++++---- contracts/protocol.ts | 27 +++++ contracts/schemas/approval.schema.json | 23 ++-- contracts/schemas/capability.schema.json | 29 +++-- contracts/schemas/error.schema.json | 27 +++-- contracts/schemas/http.schema.json | 139 +++++++++++++++++++++++ contracts/schemas/receipt.schema.json | 25 ++-- contracts/schemas/session.schema.json | 27 +++-- docs/spec/protocol.md | 2 +- scripts/check-contracts.test.mjs | 111 ++++++++++++++---- 10 files changed, 351 insertions(+), 105 deletions(-) create mode 100644 contracts/schemas/http.schema.json diff --git a/contracts/fixtures/http.json b/contracts/fixtures/http.json index f06f2ca..a1bafa1 100644 --- a/contracts/fixtures/http.json +++ b/contracts/fixtures/http.json @@ -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 } } } } \ No newline at end of file diff --git a/contracts/protocol.ts b/contracts/protocol.ts index dd02fca..25b4b90 100644 --- a/contracts/protocol.ts +++ b/contracts/protocol.ts @@ -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; diff --git a/contracts/schemas/approval.schema.json b/contracts/schemas/approval.schema.json index 5f44512..3a0ae9b 100644 --- a/contracts/schemas/approval.schema.json +++ b/contracts/schemas/approval.schema.json @@ -3,13 +3,18 @@ "$id": "https://aps.local/contracts/approval.schema.json", "title": "ApprovalDecisionRequest", "description": "客户端对确认卡的响应(架构设计 4.2:确认卡防的是误解意图,不是防越权)。", - "type": "object", - "required": ["decision", "revision", "idempotencyKey"], - "properties": { - "decision": { "type": "string", "enum": ["approve", "reject"] }, - "revision": { "type": "integer", "minimum": 0 }, - "idempotencyKey": { "type": "string", "minLength": 8 }, - "note": { "type": "string" } - }, - "additionalProperties": false + "$ref": "#/$defs/ApprovalDecisionRequest", + "$defs": { + "ApprovalDecisionRequest": { + "type": "object", + "required": ["decision", "revision", "idempotencyKey"], + "properties": { + "decision": { "type": "string", "enum": ["approve", "reject"] }, + "revision": { "type": "integer", "minimum": 0 }, + "idempotencyKey": { "type": "string", "minLength": 8 }, + "note": { "type": "string" } + }, + "additionalProperties": false + } + } } \ No newline at end of file diff --git a/contracts/schemas/capability.schema.json b/contracts/schemas/capability.schema.json index acab73e..5af8c12 100644 --- a/contracts/schemas/capability.schema.json +++ b/contracts/schemas/capability.schema.json @@ -3,16 +3,21 @@ "$id": "https://aps.local/contracts/capability.schema.json", "title": "CapabilitySummary", "description": "按身份过滤后交给客户端与引擎的能力条目。描述七要素见 docs/spec/capability-description.md。", - "type": "object", - "required": ["id", "providerId", "title", "purpose", "effect"], - "properties": { - "id": { "type": "string", "pattern": "^[a-z0-9]+(\\.[a-z0-9-]+)+$" }, - "providerId": { "type": "string" }, - "title": { "type": "string" }, - "purpose": { "type": "string", "description": "一句话用途,40 字以内" }, - "effect": { "type": "string", "enum": ["read", "draft", "write"] }, - "inputSchema": { "type": "object" }, - "synonyms": { "type": "array", "items": { "type": "string" }, "description": "业务黑话与同义词" } - }, - "additionalProperties": false + "$ref": "#/$defs/CapabilitySummary", + "$defs": { + "CapabilitySummary": { + "type": "object", + "required": ["id", "providerId", "title", "purpose", "effect"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(\\.[a-z0-9-]+)+$" }, + "providerId": { "type": "string" }, + "title": { "type": "string" }, + "purpose": { "type": "string", "description": "一句话用途,40 字以内" }, + "effect": { "type": "string", "enum": ["read", "draft", "write"] }, + "inputSchema": { "type": "object" }, + "synonyms": { "type": "array", "items": { "type": "string" }, "description": "业务黑话与同义词" } + }, + "additionalProperties": false + } + } } \ No newline at end of file diff --git a/contracts/schemas/error.schema.json b/contracts/schemas/error.schema.json index 212af85..8f56dd5 100644 --- a/contracts/schemas/error.schema.json +++ b/contracts/schemas/error.schema.json @@ -3,20 +3,25 @@ "$id": "https://aps.local/contracts/error.schema.json", "title": "ErrorEnvelope", "description": "HTTP 错误统一形状;SSE 里的错误见 events.schema.json 的 error 事件。", - "type": "object", - "required": ["error"], - "properties": { - "error": { + "$ref": "#/$defs/ErrorEnvelope", + "$defs": { + "ErrorEnvelope": { "type": "object", - "required": ["code", "message", "retryable"], + "required": ["error"], "properties": { - "code": { "type": "string" }, - "message": { "type": "string" }, - "retryable": { "type": "boolean" }, - "detail": {} + "error": { + "type": "object", + "required": ["code", "message", "retryable"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" }, + "retryable": { "type": "boolean" }, + "detail": {} + }, + "additionalProperties": false + } }, "additionalProperties": false } - }, - "additionalProperties": false + } } \ No newline at end of file diff --git a/contracts/schemas/http.schema.json b/contracts/schemas/http.schema.json new file mode 100644 index 0000000..4b241fc --- /dev/null +++ b/contracts/schemas/http.schema.json @@ -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 + } + } +} \ No newline at end of file diff --git a/contracts/schemas/receipt.schema.json b/contracts/schemas/receipt.schema.json index 3f4494d..66c3aa9 100644 --- a/contracts/schemas/receipt.schema.json +++ b/contracts/schemas/receipt.schema.json @@ -3,14 +3,19 @@ "$id": "https://aps.local/contracts/receipt.schema.json", "title": "ExecutionReceipt", "description": "能力调用的统一回执(架构设计 4.2)。", - "type": "object", - "required": ["executionId", "status"], - "properties": { - "executionId": { "type": "string" }, - "status": { "$ref": "envelope.schema.json#/$defs/status" }, - "result": {}, - "errorKind": { "type": "string" }, - "approvalId": { "type": "string", "description": "status = pending-confirmation 时给出待确认动作标识" } - }, - "additionalProperties": false + "$ref": "#/$defs/ExecutionReceipt", + "$defs": { + "ExecutionReceipt": { + "type": "object", + "required": ["executionId", "status"], + "properties": { + "executionId": { "type": "string" }, + "status": { "$ref": "envelope.schema.json#/$defs/status" }, + "result": {}, + "errorKind": { "type": "string" }, + "approvalId": { "type": "string", "description": "status = pending-confirmation 时给出待确认动作标识" } + }, + "additionalProperties": false + } + } } \ No newline at end of file diff --git a/contracts/schemas/session.schema.json b/contracts/schemas/session.schema.json index 721653f..7c3fe35 100644 --- a/contracts/schemas/session.schema.json +++ b/contracts/schemas/session.schema.json @@ -3,15 +3,20 @@ "$id": "https://aps.local/contracts/session.schema.json", "title": "SessionSummary", "description": "会话概要(ADR-0003:个人会话只读,写操作必须在项目会话内)。", - "type": "object", - "required": ["sessionId", "scope", "status", "title", "createdAt"], - "properties": { - "sessionId": { "type": "string" }, - "projectId": { "type": ["string", "null"] }, - "scope": { "type": "string", "enum": ["personal", "project"] }, - "status": { "type": "string", "enum": ["active", "archived"] }, - "title": { "type": "string" }, - "createdAt": { "type": "integer" } - }, - "additionalProperties": false + "$ref": "#/$defs/SessionSummary", + "$defs": { + "SessionSummary": { + "type": "object", + "required": ["sessionId", "scope", "status", "title", "createdAt"], + "properties": { + "sessionId": { "type": "string" }, + "projectId": { "type": ["string", "null"] }, + "scope": { "type": "string", "enum": ["personal", "project"] }, + "status": { "type": "string", "enum": ["active", "archived"] }, + "title": { "type": "string" }, + "createdAt": { "type": "integer" } + }, + "additionalProperties": false + } + } } \ No newline at end of file diff --git a/docs/spec/protocol.md b/docs/spec/protocol.md index f6df0c5..a046ec2 100644 --- a/docs/spec/protocol.md +++ b/docs/spec/protocol.md @@ -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 节的表) \ No newline at end of file diff --git a/scripts/check-contracts.test.mjs b/scripts/check-contracts.test.mjs index 6308348..a5ec1f1 100644 --- a/scripts/check-contracts.test.mjs +++ b/scripts/check-contracts.test.mjs @@ -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')}`); }); \ No newline at end of file