@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +8 -7
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +487 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/agent-notice.ts +48 -0
  15. package/src/alias-filter.test.ts +72 -0
  16. package/src/alias-filter.ts +42 -0
  17. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  18. package/src/api-host-supervisors-fallback.test.ts +271 -0
  19. package/src/api-nodes-shape.test.ts +188 -0
  20. package/src/auth-sessions-http.test.ts +250 -0
  21. package/src/auth-validate.test.ts +5 -1
  22. package/src/auth.ts +493 -35
  23. package/src/avatar-validate.test.ts +163 -0
  24. package/src/avatar-validate.ts +103 -0
  25. package/src/blocked-exit-on-reply.test.ts +122 -0
  26. package/src/boot-side-effect.test.ts +124 -0
  27. package/src/commhub-bin-args.test.ts +85 -0
  28. package/src/config-apply-sec1.test.ts +270 -1
  29. package/src/config-apply-validate.test.ts +92 -0
  30. package/src/config-apply-validate.ts +81 -3
  31. package/src/create-node-blocked-gate.test.ts +356 -0
  32. package/src/create-node-tool-schema.test.ts +39 -0
  33. package/src/create-node-validate.test.ts +275 -0
  34. package/src/create-node-validate.ts +288 -0
  35. package/src/create-node-workdir.test.ts +255 -0
  36. package/src/create-node.test.ts +354 -0
  37. package/src/create-node.ts +349 -0
  38. package/src/dashboard-slash-routing-http.test.ts +107 -0
  39. package/src/db-adapter-guard.test.ts +148 -0
  40. package/src/db-adapter-translate.test.ts +128 -0
  41. package/src/db-adapter.ts +323 -87
  42. package/src/db-timestamp.test.ts +38 -0
  43. package/src/db-timestamp.ts +15 -0
  44. package/src/db.ts +984 -34
  45. package/src/delivered-without-push.test.ts +140 -0
  46. package/src/department-groups-http.test.ts +209 -0
  47. package/src/department-groups-sync-http.test.ts +328 -0
  48. package/src/department-groups.ts +434 -0
  49. package/src/department-heads-http.test.ts +329 -0
  50. package/src/department-heads.ts +226 -0
  51. package/src/departments-http.test.ts +185 -0
  52. package/src/departments.ts +260 -0
  53. package/src/desktop-message-seam.test.ts +150 -0
  54. package/src/desktop-message.test.ts +368 -0
  55. package/src/dm-file-scope-http.test.ts +178 -0
  56. package/src/ext-token-shared.test.ts +60 -0
  57. package/src/ext-validation-regression.test.ts +131 -0
  58. package/src/external-schedule-edits-http.test.ts +321 -0
  59. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  60. package/src/external-schedule-edits.ts +327 -0
  61. package/src/file-download-authz-dev-open.test.ts +135 -0
  62. package/src/file-download-authz.test.ts +404 -0
  63. package/src/file-download-date-bucket.test.ts +436 -0
  64. package/src/file-network-scope-dev-open.test.ts +97 -0
  65. package/src/file-network-scope.test.ts +732 -0
  66. package/src/group-membership-events-http.test.ts +264 -0
  67. package/src/group-messages-http.test.ts +482 -0
  68. package/src/group-messages.ts +297 -0
  69. package/src/health-redaction.test.ts +642 -0
  70. package/src/http-gzip.test.ts +94 -0
  71. package/src/http-gzip.ts +112 -0
  72. package/src/hub-timestamp.test.ts +38 -0
  73. package/src/hub-timestamp.ts +27 -0
  74. package/src/human-dm.ts +215 -0
  75. package/src/inbox-count-sse.test.ts +310 -0
  76. package/src/inbox-count.ts +29 -0
  77. package/src/index.ts +15 -2172
  78. package/src/lifecycle-guard.ts +50 -0
  79. package/src/list-host-supervisors.test.ts +314 -0
  80. package/src/mcp-context-cost-http.test.ts +119 -0
  81. package/src/mcp-projects-events-http.test.ts +162 -0
  82. package/src/mcp-route-label-http.test.ts +120 -0
  83. package/src/mcp-write-network-resolution.test.ts +429 -0
  84. package/src/member-audit-network-id-http.test.ts +93 -0
  85. package/src/member-presence-http.test.ts +206 -0
  86. package/src/messages-ack-by-agent-http.test.ts +142 -0
  87. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  88. package/src/migrate-sqlite-to-pg.ts +263 -0
  89. package/src/model-auth-notify-http.test.ts +181 -0
  90. package/src/model-auth-notify.ts +116 -0
  91. package/src/network-name-http.test.ts +75 -0
  92. package/src/network-scope.ts +224 -0
  93. package/src/network-token-membership-http.test.ts +139 -0
  94. package/src/node-attrs-validate.ts +88 -0
  95. package/src/node-attrs.test.ts +217 -0
  96. package/src/node-create-request-status-http.test.ts +92 -0
  97. package/src/node-files-transport.test.ts +344 -0
  98. package/src/node-health-dispatch-http.test.ts +279 -0
  99. package/src/node-health-guard.ts +105 -0
  100. package/src/node-health-passthrough.test.ts +115 -0
  101. package/src/node-health-store.ts +72 -0
  102. package/src/node-id-alias.test.ts +188 -0
  103. package/src/node-identity-conflict-http.test.ts +269 -0
  104. package/src/node-identity-conflict.ts +142 -0
  105. package/src/node-identity-mismatch.test.ts +74 -0
  106. package/src/node-lifecycle-controllable.test.ts +64 -0
  107. package/src/node-lifecycle-controllable.ts +51 -0
  108. package/src/node-logs-transport.test.ts +263 -0
  109. package/src/node-permissions-http.test.ts +276 -0
  110. package/src/node-permissions.ts +242 -0
  111. package/src/node-request-retention.test.ts +281 -0
  112. package/src/node-request-retention.ts +120 -0
  113. package/src/node-skills-transport.test.ts +209 -0
  114. package/src/node-token-privilege-http.test.ts +149 -0
  115. package/src/observer-avatar-http.test.ts +431 -0
  116. package/src/observer-push.test.ts +252 -0
  117. package/src/package-version-consistency.test.ts +25 -0
  118. package/src/peer-reply-atomic.test.ts +455 -0
  119. package/src/peer-reply-capability.test.ts +70 -0
  120. package/src/pg-worker.ts +87 -0
  121. package/src/probe-validate.test.ts +221 -0
  122. package/src/probe-validate.ts +104 -0
  123. package/src/probe.test.ts +80 -0
  124. package/src/probe.ts +200 -0
  125. package/src/project-viewer-can-http.test.ts +114 -0
  126. package/src/push.ts +429 -10
  127. package/src/redact-tokens.test.ts +65 -0
  128. package/src/redact-tokens.ts +40 -0
  129. package/src/rename.ts +7 -2
  130. package/src/reply-target-derivation.test.ts +96 -0
  131. package/src/report-status-host-schema.test.ts +211 -0
  132. package/src/report-status-resume-id-handover.test.ts +150 -0
  133. package/src/require-explicit-test-db.ts +9 -0
  134. package/src/requirement-due-reminders-http.test.ts +518 -0
  135. package/src/requirement-due-reminders.ts +369 -0
  136. package/src/requirement-events-http.test.ts +257 -0
  137. package/src/requirement-events.test.ts +45 -0
  138. package/src/requirement-events.ts +144 -0
  139. package/src/requirement-notify.ts +165 -0
  140. package/src/requirement-participant-notify-http.test.ts +309 -0
  141. package/src/requirement-tag-ops-http.test.ts +266 -0
  142. package/src/requirement-tags.ts +89 -0
  143. package/src/requirements-comment-http.test.ts +165 -0
  144. package/src/requirements-completed-at-migration.test.ts +70 -0
  145. package/src/requirements-errors-mcp-http.test.ts +164 -0
  146. package/src/requirements-errors.ts +143 -0
  147. package/src/requirements-etag-http.test.ts +91 -0
  148. package/src/requirements-http.test.ts +709 -0
  149. package/src/requirements-last-event-http.test.ts +344 -0
  150. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  151. package/src/requirements-list-slim-http.test.ts +319 -0
  152. package/src/requirements-mcp.test.ts +163 -0
  153. package/src/requirements-migrate.ts +225 -0
  154. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  155. package/src/requirements-people-http.test.ts +114 -0
  156. package/src/requirements-people-mcp-http.test.ts +225 -0
  157. package/src/requirements-people.ts +127 -0
  158. package/src/requirements-priority-lowest.test.ts +114 -0
  159. package/src/requirements-search-http.test.ts +182 -0
  160. package/src/requirements-search.test.ts +32 -0
  161. package/src/requirements-search.ts +145 -0
  162. package/src/requirements-seq-http.test.ts +193 -0
  163. package/src/requirements-seq-migration.test.ts +63 -0
  164. package/src/requirements-start-date.test.ts +125 -0
  165. package/src/requirements-stats-http.test.ts +288 -0
  166. package/src/requirements-stats.ts +164 -0
  167. package/src/requirements-status-participants-http.test.ts +220 -0
  168. package/src/requirements-upgrade-e2e.test.ts +126 -0
  169. package/src/requirements-upgrade-migration.test.ts +98 -0
  170. package/src/requirements.ts +1761 -0
  171. package/src/resolve-port.test.ts +57 -0
  172. package/src/resolve-port.ts +44 -0
  173. package/src/response-charset.test.ts +130 -0
  174. package/src/rest-explicit-columns-http.test.ts +222 -0
  175. package/src/rest-identity.test.ts +129 -0
  176. package/src/rest-identity.ts +100 -0
  177. package/src/rest-projections.ts +68 -0
  178. package/src/rest-write-network-resolution.test.ts +133 -0
  179. package/src/restricted-files.ts +73 -0
  180. package/src/retention.ts +15 -3
  181. package/src/route-callers-http.test.ts +120 -0
  182. package/src/route-timing.test.ts +211 -0
  183. package/src/route-timing.ts +258 -0
  184. package/src/rules-file-duplicate-alias.test.ts +234 -0
  185. package/src/rules-file-session-target.test.ts +207 -0
  186. package/src/rules-file-transport.test.ts +237 -0
  187. package/src/runtime-label.test.ts +22 -0
  188. package/src/runtime-label.ts +21 -0
  189. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  190. package/src/scheduled-failures-http.test.ts +233 -0
  191. package/src/scheduled-failures.ts +179 -0
  192. package/src/scheduled-reply-unread-http.test.ts +212 -0
  193. package/src/scheduled-run-terminal.test.ts +268 -0
  194. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  195. package/src/scheduled-stuck-http.test.ts +238 -0
  196. package/src/scheduled-stuck.ts +153 -0
  197. package/src/scheduled-tasks-http.test.ts +638 -0
  198. package/src/scheduled-tasks.ts +650 -0
  199. package/src/send-dedup-request-id.test.ts +229 -0
  200. package/src/send-reply-agent-warning.test.ts +202 -0
  201. package/src/send-reply-alias-optional.test.ts +131 -0
  202. package/src/send-reply-attachments.test.ts +458 -0
  203. package/src/send_dedup.test.ts +54 -0
  204. package/src/send_dedup.ts +52 -6
  205. package/src/serve-error.test.ts +38 -0
  206. package/src/serve-error.ts +27 -0
  207. package/src/server.ts +5038 -0
  208. package/src/shared/external-schedule-contract.ts +60 -0
  209. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  210. package/src/shared/probe-host-allowlist.ts +128 -0
  211. package/src/shared/reserved-env-drift.test.ts +43 -0
  212. package/src/shared/reserved-env.test.ts +35 -0
  213. package/src/shared/reserved-env.ts +31 -0
  214. package/src/side-thread-command-transport.test.ts +312 -0
  215. package/src/side-thread-command-transport.ts +318 -0
  216. package/src/side-thread-http-integration.test.ts +233 -0
  217. package/src/side-thread-http.ts +504 -0
  218. package/src/side-thread-production.test.ts +72 -0
  219. package/src/side-thread-production.ts +118 -0
  220. package/src/side-thread.test.ts +1067 -0
  221. package/src/side-thread.ts +2499 -0
  222. package/src/skillhub-http.test.ts +108 -0
  223. package/src/sqlite-close-checkpoint.test.ts +78 -0
  224. package/src/start-node.test.ts +109 -0
  225. package/src/status-alias-filter-http.test.ts +106 -0
  226. package/src/status-alias-resolver-http.test.ts +163 -0
  227. package/src/status-alias-resolver.ts +19 -0
  228. package/src/status-node-id-filter-http.test.ts +161 -0
  229. package/src/status-read-cache-http.test.ts +229 -0
  230. package/src/status-read-cache.ts +122 -0
  231. package/src/stop-delete-node.test.ts +1164 -0
  232. package/src/task-access-http.test.ts +379 -0
  233. package/src/task-access.ts +413 -0
  234. package/src/task-auth-origin-http.test.ts +91 -0
  235. package/src/task-auth-origin.test.ts +24 -0
  236. package/src/task-auth-origin.ts +7 -0
  237. package/src/task-consumption.test.ts +482 -0
  238. package/src/task-diagnostic-http.test.ts +149 -0
  239. package/src/task-diagnostic.test.ts +115 -0
  240. package/src/task-diagnostic.ts +118 -0
  241. package/src/task-expiry-notice-http.test.ts +287 -0
  242. package/src/task-expiry-notice.ts +313 -0
  243. package/src/task-idempotency-mcp.test.ts +142 -0
  244. package/src/task-idempotency.test.ts +60 -0
  245. package/src/task-idempotency.ts +75 -0
  246. package/src/task-lifecycle-watcher.test.ts +204 -0
  247. package/src/task-lifecycle-watcher.ts +49 -0
  248. package/src/task-network-resolution-http.test.ts +141 -0
  249. package/src/task-queue-ahead-http.test.ts +309 -0
  250. package/src/task-queue-ahead-scale.test.ts +211 -0
  251. package/src/task-queue-ahead.ts +186 -0
  252. package/src/task-stale-open-http.test.ts +290 -0
  253. package/src/task-stale-open.ts +103 -0
  254. package/src/tasks-pagination-http.test.ts +141 -0
  255. package/src/tool-audience-http.test.ts +195 -0
  256. package/src/tool-audience.ts +109 -0
  257. package/src/tools-list-json-schema.test.ts +67 -0
  258. package/src/tools.ts +4955 -300
  259. package/src/update-provider.test.ts +234 -0
  260. package/src/upload-early-reject-guard.test.ts +155 -0
  261. package/src/uploads-http.test.ts +97 -15
  262. package/src/uploads.test.ts +66 -0
  263. package/src/uploads.ts +138 -5
  264. package/src/user-inbox-by-agent-http.test.ts +161 -0
  265. package/src/user-inbox-netid-notnull.test.ts +122 -0
  266. package/src/user-inbox-read-http.test.ts +223 -0
  267. package/src/user-inbox-schema.test.ts +54 -0
  268. package/src/vault.test.ts +200 -0
  269. package/src/vault.ts +156 -0
@@ -0,0 +1,89 @@
1
+ /** Small, network-local labels; count Unicode code points rather than UTF-16 units. */
2
+ export function normalizeTags(value: unknown): string[] | null {
3
+ if (!Array.isArray(value) || value.length > 10) return null;
4
+ const tags: string[] = [];
5
+ for (const valueTag of value) {
6
+ if (typeof valueTag !== "string") return null;
7
+ const tag = valueTag.trim();
8
+ if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return null;
9
+ if (!tags.includes(tag)) tags.push(tag);
10
+ }
11
+ return tags;
12
+ }
13
+
14
+ export function storedTags(value: string | null): string[] {
15
+ try { return normalizeTags(JSON.parse(value || "[]")) ?? []; } catch { return []; }
16
+ }
17
+
18
+ // ── 标签管理(改名 / 合并 / 删除 / 颜色) ──
19
+ // 标签只是卡片 tags 数组里的字符串,没有自己的表;批量操作 = 把网络里每张带这个标签的卡的数组改写一遍。
20
+ // 颜色是可选的,单独存在 network_tags(network_id, name, color);没行 = 客户端用默认灰。
21
+ export const MAX_MERGE_SOURCES = 50;
22
+ export type TagOp =
23
+ | { op: "rename"; from: string; to: string }
24
+ | { op: "merge"; from: string[]; to: string }
25
+ | { op: "delete"; tag: string }
26
+ | { op: "color"; tag: string; color: string | null };
27
+
28
+ /** 单个标签名,规则同 normalizeTags。不合法 = null。 */
29
+ export function tagName(value: unknown): string | null {
30
+ const tags = normalizeTags([value]);
31
+ return tags && tags.length === 1 ? tags[0] : null;
32
+ }
33
+
34
+ const TAG_COLOR = /^#[0-9a-fA-F]{6}$/;
35
+ /** 请求体 → 操作;不合法返回错误码。 */
36
+ export function parseTagOp(body: Record<string, unknown>): TagOp | { error: string } {
37
+ switch (body.op) {
38
+ case "rename": {
39
+ const from = tagName(body.from), to = tagName(body.to);
40
+ if (!from || !to) return { error: "invalid_tag" };
41
+ if (from === to) return { error: "same_tag" };
42
+ return { op: "rename", from, to };
43
+ }
44
+ case "merge": {
45
+ if (!Array.isArray(body.from) || body.from.length === 0 || body.from.length > MAX_MERGE_SOURCES) return { error: "invalid_tag" };
46
+ const from: string[] = [];
47
+ for (const value of body.from) {
48
+ const tag = tagName(value);
49
+ if (!tag) return { error: "invalid_tag" };
50
+ if (!from.includes(tag)) from.push(tag);
51
+ }
52
+ const to = tagName(body.to);
53
+ if (!to) return { error: "invalid_tag" };
54
+ // 目标自己出现在来源里不算来源(合并进自己 = 什么都不做)。
55
+ const sources = from.filter(tag => tag !== to);
56
+ if (!sources.length) return { error: "same_tag" };
57
+ return { op: "merge", from: sources, to };
58
+ }
59
+ case "delete": {
60
+ const tag = tagName(body.tag);
61
+ return tag ? { op: "delete", tag } : { error: "invalid_tag" };
62
+ }
63
+ case "color": {
64
+ const tag = tagName(body.tag);
65
+ if (!tag) return { error: "invalid_tag" };
66
+ if (body.color !== null && (typeof body.color !== "string" || !TAG_COLOR.test(body.color))) return { error: "invalid_tag_color" };
67
+ return { op: "color", tag, color: body.color === null ? null : (body.color as string).toLowerCase() };
68
+ }
69
+ default:
70
+ return { error: "invalid_tag_op" };
71
+ }
72
+ }
73
+
74
+ /** 这张卡的标签被这个操作改写后的样子;没变返回 null。顺序保留(新名字占第一个来源的位置),去重。 */
75
+ export function applyTagOp(tags: string[], op: TagOp): string[] | null {
76
+ let sources: string[];
77
+ let to: string | null;
78
+ if (op.op === "rename") { sources = [op.from]; to = op.to; }
79
+ else if (op.op === "merge") { sources = op.from; to = op.to; }
80
+ else if (op.op === "delete") { sources = [op.tag]; to = null; }
81
+ else return null;
82
+ if (!tags.some(tag => sources.includes(tag))) return null;
83
+ const next: string[] = [];
84
+ for (const tag of tags) {
85
+ const mapped = sources.includes(tag) ? to : tag;
86
+ if (mapped !== null && !next.includes(mapped)) next.push(mapped);
87
+ }
88
+ return next;
89
+ }
@@ -0,0 +1,165 @@
1
+ // #474(MCP 任务生命周期测试报告问题 5)—— 评论 / 进展:只追加,进「动态」(requirement_events kind=comment),
2
+ // 不改任务本身。钉住:
3
+ // - Agent(节点令牌)经 MCP、人经 REST 都能评论,署名是调用者;description / updated_at 一个字节都不动;
4
+ // - requirements_events 里是 kind=comment、new.text 全文,单任务的动态体积可控;
5
+ // - 看不见这张卡的人评论 = 404(和读一样),只读角色 = 403,空 / 超长 = 400 + 提示;
6
+ // - 只追加:没有改 / 删评论的路由。
7
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "fs";
9
+ import { join } from "path";
10
+ import { tmpdir } from "os";
11
+ import { register, createNetworkTokenForNode } from "./auth.js";
12
+ import { db } from "./db.js";
13
+ import { COMMENT_MAX_CHARS } from "./requirements.js";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-comment-"));
16
+ let BASE = "";
17
+ let hub: any = null;
18
+ const PW = "ReqCommentPassw0rd!xyz";
19
+ let NET = "";
20
+ const U: Record<string, { token: string; id: string }> = {};
21
+ let card = { id: "", seq: 0 };
22
+
23
+ async function rest(token: string, method: string, path: string, body?: unknown) {
24
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
25
+ const text = await res.text();
26
+ let json: any = null;
27
+ try { json = JSON.parse(text); } catch {}
28
+ return { status: res.status, body: json, bytes: text.length };
29
+ }
30
+ async function rpc(token: string, name: string, args: Record<string, unknown>): Promise<{ data: any; raw: any; bytes: number }> {
31
+ const res = await fetch(`${BASE}/mcp`, {
32
+ method: "POST",
33
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
34
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
35
+ });
36
+ const raw = await res.text();
37
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
38
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
39
+ const text = out.result?.content?.[0]?.text ?? "";
40
+ let data: any = null;
41
+ try { data = JSON.parse(text); } catch { data = text; }
42
+ return { data, raw: out, bytes: text.length };
43
+ }
44
+ const mcp = async (token: string, name: string, args: Record<string, unknown>) => (await rpc(token, name, args)).data;
45
+ const cardRow = (id: string) => db.get<{ description: string | null; updated_at: string | null; updated_by_json: string | null }>("SELECT description, updated_at, updated_by_json FROM requirements WHERE requirement_id = ?1", id)!;
46
+
47
+ beforeAll(async () => {
48
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
49
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
50
+ process.env.HOST = "127.0.0.1";
51
+ const a = register(`cmt_owner_${Date.now()}`, PW, undefined, "Owner");
52
+ U.owner = { token: a.token!, id: a.user!.user_id };
53
+ NET = a.network_id!;
54
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
55
+ const mod: any = await import("./server.js");
56
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
57
+ BASE = `http://127.0.0.1:${hub.port}`;
58
+ const mk = async (username: string, role: string) => {
59
+ expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
60
+ const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
61
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
62
+ };
63
+ const stamp = Date.now();
64
+ U.member = await mk(`cmt_member_${stamp}`, "member");
65
+ U.viewer = await mk(`cmt_viewer_${stamp}`, "viewer");
66
+ U.scoped = await mk(`cmt_scoped_${stamp}`, "member");
67
+ expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.scoped.id}/task-grants`, { task_access: "scoped", project_grants: [] })).status).toBe(200);
68
+ U.nodeA = { token: createNetworkTokenForNode(U.owner.id, NET, "示例-甲", "node_cmt_a").token!, id: "node_cmt_a" };
69
+ const created = (await mcp(U.nodeA.token, "requirements_create", { name: "示例任务:评论", description: "## 背景\n原文。" })).requirement;
70
+ card = { id: created.id, seq: created.seq };
71
+ }, 30_000);
72
+
73
+ afterAll(() => {
74
+ try { hub?.stop?.(true); } catch {}
75
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
76
+ });
77
+
78
+ describe("requirements_comment", () => {
79
+ test("an Agent comments over MCP (#N): one comment event, attributed to the node; the task itself is untouched", async () => {
80
+ const before = cardRow(card.id);
81
+ const r = await mcp(U.nodeA.token, "requirements_comment", { id: `#${card.seq}`, text: "进展:示例草稿已写完,等人看。" });
82
+ expect(r.ok).toBe(true);
83
+ expect(r.event.kind).toBe("comment");
84
+ expect(r.event.field).toBeNull();
85
+ expect(r.event.new).toEqual({ text: "进展:示例草稿已写完,等人看。" });
86
+ expect(r.event.actor).toEqual({ kind: "node", id: "node_cmt_a" });
87
+ expect(r.event.requirement_id).toBe(card.id);
88
+ const after = cardRow(card.id);
89
+ expect(after).toEqual(before); // description, updated_at, updated_by all unchanged
90
+ });
91
+
92
+ test("a person comments over REST; both show in requirements_events with the full text, newest first", async () => {
93
+ const r = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: " 好的,我下午看。 " });
94
+ expect(r.status).toBe(201);
95
+ expect(r.body.event.new.text).toBe("好的,我下午看。"); // trimmed
96
+ expect(r.body.event.actor).toEqual({ kind: "user", id: U.member.id });
97
+ const ev = await rpc(U.nodeA.token, "requirements_events", { requirement_id: `#${card.seq}` });
98
+ const comments = ev.data.events.filter((e: any) => e.kind === "comment");
99
+ expect(comments.map((e: any) => e.new.text)).toEqual(["好的,我下午看。", "进展:示例草稿已写完,等人看。"]);
100
+ expect(ev.data.events.at(-1).kind).toBe("created");
101
+ });
102
+
103
+ test("full text up to the cap, and the timeline stays small", async () => {
104
+ const long = "长".repeat(COMMENT_MAX_CHARS);
105
+ const r = await mcp(U.nodeA.token, "requirements_comment", { id: card.id, text: long });
106
+ expect(r.ok).toBe(true);
107
+ expect(r.event.new.text.length).toBe(COMMENT_MAX_CHARS);
108
+ const ev = await rpc(U.nodeA.token, "requirements_events", { requirement_id: card.id });
109
+ // 1 created + 3 comments (one of them 4000 chars): well under 20 KB.
110
+ expect(ev.bytes).toBeLessThan(20_000);
111
+ expect(ev.data.events.some((e: any) => e.kind === "comment" && e.new.text === long)).toBe(true);
112
+ });
113
+
114
+ test("bad input: empty → invalid_comment, over the cap → comment_too_long (REST), MCP schema → -32602; both with hints", async () => {
115
+ const empty = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: " " });
116
+ expect(empty.status).toBe(400);
117
+ expect(empty.body.error).toBe("invalid_comment");
118
+ expect(empty.body.field).toBe("text");
119
+ const tooLong = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: "x".repeat(COMMENT_MAX_CHARS + 1) });
120
+ expect(tooLong.status).toBe(400);
121
+ expect(tooLong.body.error).toBe("comment_too_long");
122
+ expect(tooLong.body.hint).toContain("Split");
123
+ const viaMcp = await rpc(U.nodeA.token, "requirements_comment", { id: card.id, text: "x".repeat(COMMENT_MAX_CHARS + 1) });
124
+ expect(String(viaMcp.raw.error?.code ?? viaMcp.raw.result?.content?.[0]?.text)).toMatch(/-32602|too big|Too big/i);
125
+ });
126
+
127
+ test("permissions: a non-reader gets 404 (like reading), a read-only role 403; neither writes anything", async () => {
128
+ const count = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events WHERE requirement_id = ?1 AND kind = 'comment'", card.id)!.n;
129
+ const n = count();
130
+ // scoped member with no grant cannot see the task → cannot read it → cannot comment on it.
131
+ expect((await rest(U.scoped.token, "GET", `/api/requirements/${card.id}`)).status).toBe(404);
132
+ const scoped = await rest(U.scoped.token, "POST", `/api/requirements/${card.id}/comments`, { text: "看不见也想评论" });
133
+ expect(scoped.status).toBe(404);
134
+ expect(scoped.body.error).toBe("requirement_not_found");
135
+ const viewer = await rest(U.viewer.token, "POST", `/api/requirements/${card.id}/comments`, { text: "只读角色" });
136
+ expect(viewer.status).toBe(403);
137
+ expect(viewer.body.error).toBe("permission_denied");
138
+ expect(count()).toBe(n);
139
+ // …and the scoped member does not see the comments in the network timeline either.
140
+ const ev = await rest(U.scoped.token, "GET", `/api/requirements/events?network_id=${NET}`);
141
+ expect((ev.body.events ?? []).some((e: any) => e.requirement_id === card.id)).toBe(false);
142
+ });
143
+
144
+ test("append-only: no route edits or deletes a comment; unknown task → requirement_not_found with a hint", async () => {
145
+ expect((await rest(U.owner.token, "PATCH", `/api/requirements/${card.id}/comments`, { text: "改" })).status).toBe(404);
146
+ expect((await rest(U.owner.token, "DELETE", `/api/requirements/${card.id}/comments`)).status).toBe(404);
147
+ const missing = await mcp(U.nodeA.token, "requirements_comment", { id: "#9999", text: "没有这张卡" });
148
+ expect(missing.error).toBe("requirement_not_found");
149
+ expect(missing.hint).toContain("requirements_list");
150
+ });
151
+
152
+ test("discoverable: tools/list carries requirements_comment, and requirements_events mentions comments", async () => {
153
+ const res = await fetch(`${BASE}/mcp`, {
154
+ method: "POST",
155
+ headers: { Authorization: `Bearer ${U.nodeA.token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
156
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
157
+ });
158
+ const raw = await res.text();
159
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
160
+ const tools = (lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw)).result.tools as any[];
161
+ const byName = new Map(tools.map(t => [t.name, t]));
162
+ expect(String(byName.get("requirements_comment")?.description)).toContain("append-only");
163
+ expect(String(byName.get("requirements_events")?.description)).toContain("kind=comment");
164
+ });
165
+ });
@@ -0,0 +1,70 @@
1
+ // 完成时间的启动迁移(requirements-migrate.ts ensureRequirementCompletedAt):用加完成时间之前的表结构建库、塞行,
2
+ // 再 import db.ts + requirements.ts(真实启动路径)。覆盖:两种旧时间形状、没有 updated_at 的行、
3
+ // 回滚到旧 Hub 期间「移出完成」留下的残值被清掉、「移进完成」的卡被补上,以及重复执行不再改任何东西。
4
+ import { afterAll, expect, test } from "bun:test";
5
+ import { Database } from "bun:sqlite";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-completed-at-"));
11
+ const file = join(dir, "hub.db");
12
+ process.env.COMMHUB_DB = file;
13
+
14
+ const legacy = new Database(file);
15
+ legacy.exec(`CREATE TABLE requirements (
16
+ requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
17
+ column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')), priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
18
+ client_id TEXT, issues_json TEXT, created_by TEXT,
19
+ created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
20
+ owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]', updated_by_json TEXT)`);
21
+ const seed: [string, string, string, string][] = [
22
+ // id, column, created_at, updated_at
23
+ ["r_done_sqlite_ts", "done", "2026-08-01 08:00:00", "2026-09-01 10:00:00"],
24
+ ["r_done_iso", "done", "2026-08-02T00:00:00.000Z", "2026-09-02T03:04:05.678Z"],
25
+ ["r_done_bad_updated", "done", "2026-08-03T00:00:00.000Z", "not a time"],
26
+ ["r_doing", "doing", "2026-08-04T00:00:00.000Z", "2026-09-04T00:00:00.000Z"],
27
+ ["r_pool", "pool", "2026-08-05T00:00:00.000Z", "2026-09-05T00:00:00.000Z"],
28
+ ];
29
+ for (const [id, column, createdAt, updatedAt] of seed) {
30
+ legacy.run("INSERT INTO requirements (requirement_id, network_id, title, column_name, created_at, updated_at, updated_by_json) VALUES (?1, 'net', ?1, ?2, ?3, ?4, ?5)",
31
+ [id, column, createdAt, updatedAt, JSON.stringify({ kind: "user", id: "u_last_editor" })]);
32
+ }
33
+ legacy.close();
34
+
35
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
36
+
37
+ const read = (db: any) => Object.fromEntries((db.all("SELECT requirement_id, column_name, updated_at, completed_at, completed_by_json, completed_at_approx FROM requirements") as any[]).map(r => [r.requirement_id, r]));
38
+
39
+ test("backfill: done rows get an approximate ISO completed_at from updated_at; others stay empty; idempotent", async () => {
40
+ const { db } = await import("./db.js");
41
+ await import("./requirements.js");
42
+ const { ensureRequirementCompletedAt } = await import("./requirements-migrate.js");
43
+ const by = read(db);
44
+ expect(by.r_done_sqlite_ts.completed_at).toBe("2026-09-01T10:00:00.000Z");
45
+ expect(by.r_done_iso.completed_at).toBe("2026-09-02T03:04:05.678Z");
46
+ // updated_at 读不懂 → 退到 created_at,而不是留空(留空的完成卡在仪表盘上就「没完成过」)
47
+ expect(by.r_done_bad_updated.completed_at).toBe("2026-08-03T00:00:00.000Z");
48
+ for (const id of ["r_done_sqlite_ts", "r_done_iso", "r_done_bad_updated"]) {
49
+ expect(by[id].completed_at_approx).toBe(1);
50
+ // 最后改的人不是「完成的人」:近似值不猜完成者
51
+ expect(by[id].completed_by_json).toBeNull();
52
+ // 补值不碰 updated_at
53
+ expect(by[id].updated_at).toBe(seed.find(s => s[0] === id)![3]);
54
+ }
55
+ for (const id of ["r_doing", "r_pool"]) expect([by[id].completed_at, by[id].completed_by_json, by[id].completed_at_approx]).toEqual([null, null, 0]);
56
+
57
+ // 下次启动:什么都不动
58
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
59
+ expect(read(db)).toEqual(by);
60
+
61
+ // 回滚到旧 Hub 期间:旧 Hub 不认识这三列 —— 把一张完成的卡移出(列留着旧值)、把一张卡移进完成(列是空的)。
62
+ db.run("UPDATE requirements SET column_name = 'doing' WHERE requirement_id = 'r_done_iso'");
63
+ db.run("UPDATE requirements SET column_name = 'done', updated_at = '2026-09-06T00:00:00.000Z' WHERE requirement_id = 'r_pool'");
64
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 1, cleared: 1 });
65
+ const after = read(db);
66
+ expect([after.r_done_iso.completed_at, after.r_done_iso.completed_by_json, after.r_done_iso.completed_at_approx]).toEqual([null, null, 0]);
67
+ expect([after.r_pool.completed_at, after.r_pool.completed_at_approx]).toEqual(["2026-09-06T00:00:00.000Z", 1]);
68
+ expect(after.r_done_sqlite_ts).toEqual(by.r_done_sqlite_ts);
69
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
70
+ });
@@ -0,0 +1,164 @@
1
+ // #472(MCP 任务生命周期测试报告问题 4)—— 任务 / 项目接口的错误带上 field / message / hint,`error` 原样。
2
+ // 每条提示一个测试(经 MCP,以 Agent / 人的身份),外加一道取集门:requirements.ts 里返回 / 抛出的每个错误码都在
3
+ // requirements-errors.ts 的登记表里,新加的错误码没登记 ⇒ 红。
4
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
5
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
6
+ import { join } from "path";
7
+ import { tmpdir } from "os";
8
+ import { register, createNetworkTokenForNode } from "./auth.js";
9
+ import { db } from "./db.js";
10
+ import { __documentedErrorCodes, errorBody } from "./requirements-errors.js";
11
+
12
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-errors-"));
13
+ let BASE = "";
14
+ let hub: any = null;
15
+ const PW = "ReqErrorsPassw0rd!xyz";
16
+ let NET = "", ownerToken = "", ownerId = "", nodeToken = "", memberToken = "", cardId = "";
17
+
18
+ async function rest(token: string, method: string, path: string, body?: unknown) {
19
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
20
+ const text = await res.text();
21
+ let json: any = null;
22
+ try { json = JSON.parse(text); } catch {}
23
+ return { status: res.status, body: json };
24
+ }
25
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
26
+ const res = await fetch(`${BASE}/mcp`, {
27
+ method: "POST",
28
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
29
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
30
+ });
31
+ const raw = await res.text();
32
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
33
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
34
+ return JSON.parse(out.result.content[0].text);
35
+ }
36
+
37
+ beforeAll(async () => {
38
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
39
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
40
+ process.env.HOST = "127.0.0.1";
41
+ const a = register(`reqerr_owner_${Date.now()}`, PW, undefined, "Owner");
42
+ ownerToken = a.token!; ownerId = a.user!.user_id; NET = a.network_id!;
43
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [ownerId]);
44
+ nodeToken = createNetworkTokenForNode(ownerId, NET, "示例-甲", "node_reqerr_a").token!;
45
+ const mod: any = await import("./server.js");
46
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
47
+ BASE = `http://127.0.0.1:${hub.port}`;
48
+ const username = `reqerr_member_${Date.now()}`;
49
+ expect((await rest(ownerToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
50
+ memberToken = (await rest("", "POST", "/api/auth/login", { username, password: PW })).body.token;
51
+ cardId = (await mcp(nodeToken, "requirements_create", { name: "错误提示测试" })).requirement.id;
52
+ }, 30_000);
53
+
54
+ afterAll(() => {
55
+ try { hub?.stop?.(true); } catch {}
56
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
57
+ });
58
+
59
+ describe("each hint, over MCP", () => {
60
+ test("invalid_due: field due, the accepted formats", async () => {
61
+ for (const due of ["下周五", "2026/10/10"]) {
62
+ const r = await mcp(nodeToken, "requirements_update", { id: cardId, due });
63
+ expect(r.error).toBe("invalid_due");
64
+ expect(r.status).toBe(400);
65
+ expect(r.field).toBe("due");
66
+ expect(r.hint).toContain("YYYY-MM-DD");
67
+ expect(r.hint).toContain("ISO 8601");
68
+ }
69
+ expect((await mcp(nodeToken, "requirements_update", { id: cardId, start: "明天" })).field).toBe("start");
70
+ });
71
+
72
+ test("person_not_in_network: names the field, explains node_id vs user_id and where to find them", async () => {
73
+ const byAlias = await mcp(nodeToken, "requirements_update", { id: cardId, agent_owner: { kind: "node", id: "示例-甲" } });
74
+ expect(byAlias.error).toBe("person_not_in_network");
75
+ expect(byAlias.field).toBe("agent_owner");
76
+ // #473:人员字段也收名字 —— 提示改为指向 requirements_people,并说明 alias / username 的写法。
77
+ expect(byAlias.hint).toContain("requirements_people");
78
+ expect(byAlias.hint).toContain("{kind:'node', alias}");
79
+ expect((await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "user", id: "u_nobody" } })).field).toBe("owner");
80
+ expect((await mcp(nodeToken, "requirements_update", { id: cardId, participants: [{ kind: "node", id: "node_reqerr_a" }, { kind: "user", id: "u_nobody" }] })).field).toBe("participants");
81
+ });
82
+
83
+ test("project_not_in_network: a name is recognised as a name; both point to projects_list", async () => {
84
+ const byName = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "示例项目" });
85
+ expect(byName.error).toBe("project_not_in_network");
86
+ expect(byName.field).toBe("project_id");
87
+ expect(byName.message).toContain("looks like a project name");
88
+ expect(byName.hint).toContain("projects_list");
89
+ const byId = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "proj_missing" });
90
+ expect(byId.error).toBe("project_not_in_network");
91
+ expect(byId.message).not.toContain("project name");
92
+ expect(byId.hint).toContain("projects_list");
93
+ });
94
+
95
+ test("empty_patch: lists the writable fields; sending status says to use column", async () => {
96
+ // The MCP tool schema strips keys it does not know, so `status` arrives as nothing; REST sees the raw body.
97
+ const viaRest = await rest(nodeToken, "PATCH", `/api/requirements/${cardId}`, { status: "doing" });
98
+ expect(viaRest.status).toBe(400);
99
+ expect(viaRest.body.error).toBe("empty_patch");
100
+ expect(viaRest.body.hint).toContain("use column");
101
+ expect(viaRest.body.hint).toContain("Writable fields: column");
102
+ expect(viaRest.body.message).toContain("status");
103
+ const viaMcp = await mcp(nodeToken, "requirements_update", { id: cardId });
104
+ expect(viaMcp.error).toBe("empty_patch");
105
+ expect(viaMcp.hint).toContain("Writable fields:");
106
+ expect(viaMcp.hint).not.toContain("use column");
107
+ });
108
+
109
+ test("user_token_required: says projects are managed by people and what to do instead", async () => {
110
+ const r = await mcp(nodeToken, "projects_create", { name: "节点建项目" });
111
+ expect(r.error).toBe("user_token_required");
112
+ expect(r.status).toBe(403);
113
+ expect(r.message).toContain("node token");
114
+ expect(r.hint).toContain("projects_list");
115
+ });
116
+
117
+ test("network_id_required: field network_id and how to fix it", async () => {
118
+ const r = await mcp(memberToken, "projects_create", { name: "成员建项目" });
119
+ expect(r.error).toBe("network_id_required");
120
+ expect(r.field).toBe("network_id");
121
+ expect(r.hint).toContain("network_id");
122
+ });
123
+
124
+ test("swept codes carry hints too: checklist_item_not_found, external_ref_exists (existing_id kept), owner_must_be_human, parent_cycle", async () => {
125
+ const ck = await mcp(nodeToken, "requirements_checklist_toggle", { id: cardId, item_id: "nope", done: true });
126
+ expect(ck.error).toBe("checklist_item_not_found");
127
+ expect(ck.hint).toContain("requirements_get");
128
+ await mcp(nodeToken, "requirements_create", { name: "外部", external_ref: "github:example/repo#472" });
129
+ const dup = await mcp(nodeToken, "requirements_create", { name: "外部又一次", external_ref: "github:example/repo#472" });
130
+ expect(dup.error).toBe("external_ref_exists");
131
+ expect(typeof dup.existing_id).toBe("string");
132
+ expect(dup.hint).toContain("requirements_upsert_by_external_ref");
133
+ const own = await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "node", id: "node_reqerr_a" } });
134
+ expect(own.error).toBe("owner_must_be_human");
135
+ expect(own.hint).toContain("agent_owner");
136
+ const cyc = await mcp(nodeToken, "requirements_update", { id: cardId, parent_id: cardId });
137
+ expect(cyc.error).toBe("parent_cycle");
138
+ expect(cyc.field).toBe("parent_id");
139
+ });
140
+ });
141
+
142
+ describe("compatibility", () => {
143
+ test("REST keeps the exact error strings the app matches on; the new keys are additive", async () => {
144
+ const r = await rest(ownerToken, "PATCH", `/api/requirements/${cardId}`, { due: "下周五" });
145
+ expect(r.status).toBe(400);
146
+ expect(r.body.ok).toBe(false);
147
+ expect(r.body.error).toBe("invalid_due");
148
+ expect(Object.keys(r.body).sort()).toEqual(["error", "field", "hint", "message", "ok"]);
149
+ // A code without an entry is exactly the old shape.
150
+ expect(errorBody("some_future_code")).toEqual({ ok: false, error: "some_future_code" });
151
+ });
152
+
153
+ test("every error code requirements.ts returns or throws has an entry (a new code must be documented)", () => {
154
+ const src = readFileSync(join(import.meta.dir, "requirements.ts"), "utf8");
155
+ const codes = new Set<string>();
156
+ for (const m of src.matchAll(/jsonError\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
157
+ for (const m of src.matchAll(/(?:new (?:Error|RequirementFieldError))\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
158
+ for (const m of src.matchAll(/errorBody\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
159
+ for (const m of src.matchAll(/return\s+["'](parent_[a-z_]+)["']/g)) codes.add(m[1]);
160
+ expect(codes.size).toBeGreaterThan(40); // the scan really found the codes (not an empty pass)
161
+ const documented = new Set(__documentedErrorCodes());
162
+ expect([...codes].filter(c => !documented.has(c)).sort()).toEqual([]);
163
+ });
164
+ });