agentic-flow-kit 0.4.0 → 0.6.0

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 (117) hide show
  1. package/README.md +3 -0
  2. package/modules/gates/dotnet-ca/check-architecture.sh +23 -5
  3. package/modules/hosts/omp/agents/implementer.md +47 -6
  4. package/modules/hosts/omp/agents/independent-reviewer.md +44 -5
  5. package/modules/hosts/omp/agents/ui-verifier.md +43 -6
  6. package/modules/hosts/opencode/agents/implementer.md +47 -1
  7. package/modules/hosts/opencode/agents/independent-reviewer.md +44 -1
  8. package/modules/hosts/opencode/agents/ui-verifier.md +43 -1
  9. package/modules/hosts/opencode/commands/build.md +74 -7
  10. package/modules/hosts/opencode/commands/deploy.md +51 -6
  11. package/modules/hosts/opencode/commands/design.md +36 -6
  12. package/modules/hosts/opencode/commands/gate-checklist.md +77 -0
  13. package/modules/hosts/opencode/commands/maintain.md +70 -6
  14. package/modules/hosts/opencode/commands/new-task.md +59 -6
  15. package/modules/hosts/opencode/commands/plan.md +35 -6
  16. package/modules/hosts/opencode/commands/review.md +41 -6
  17. package/modules/hosts/opencode/commands/source-sync-check.md +89 -0
  18. package/modules/hosts/opencode/commands/sync-hosts.md +66 -0
  19. package/modules/hosts/opencode/commands/test.md +58 -8
  20. package/modules/hosts/trae/agents/implementer.md +46 -2
  21. package/modules/hosts/trae/agents/independent-reviewer.md +44 -1
  22. package/modules/hosts/trae/agents/ui-verifier.md +41 -3
  23. package/modules/hosts/trae/commands/wf-build.md +74 -7
  24. package/modules/hosts/trae/commands/wf-deploy.md +51 -6
  25. package/modules/hosts/trae/commands/wf-design.md +36 -6
  26. package/modules/hosts/trae/commands/wf-gate-checklist.md +78 -0
  27. package/modules/hosts/trae/commands/wf-maintain.md +70 -6
  28. package/modules/hosts/trae/commands/wf-new-task.md +59 -6
  29. package/modules/hosts/trae/commands/wf-plan.md +35 -6
  30. package/modules/hosts/trae/commands/wf-review.md +41 -6
  31. package/modules/hosts/trae/commands/wf-source-sync-check.md +90 -0
  32. package/modules/hosts/trae/commands/wf-sync-hosts.md +67 -0
  33. package/modules/hosts/trae/commands/wf-test.md +58 -7
  34. package/modules/hosts/trae/hooks/post-edit-check.cjs +3 -2
  35. package/modules/hosts/trae/hooks/pre-shell-check.cjs +40 -9
  36. package/modules/hosts/trae/hooks.json +1 -1
  37. package/modules/hosts/zcode/agents/implementer.md +47 -6
  38. package/modules/hosts/zcode/agents/independent-reviewer.md +44 -5
  39. package/modules/hosts/zcode/agents/ui-verifier.md +43 -5
  40. package/package.json +6 -3
  41. package/src/add-gate.mjs +5 -3
  42. package/src/cli.mjs +15 -2
  43. package/src/doctor.mjs +245 -15
  44. package/src/gate-dotnet-ca.test.mjs +124 -0
  45. package/src/init.mjs +79 -17
  46. package/src/init.test.mjs +84 -2
  47. package/src/pack.test.mjs +24 -0
  48. package/src/render.mjs +64 -4
  49. package/src/run-tests.mjs +7 -11
  50. package/src/sync-hosts.mjs +194 -0
  51. package/src/sync-hosts.test.mjs +170 -0
  52. package/src/sync.mjs +65 -20
  53. package/src/sync.test.mjs +52 -0
  54. package/src/trae-hooks.test.mjs +115 -0
  55. package/templates/AGENTS.md +2 -1
  56. package/templates/_agents/board/index.html +32 -3
  57. package/templates/_agents/commands/build.md +22 -1
  58. package/templates/_agents/commands/design.md +2 -0
  59. package/templates/_agents/commands/gate-checklist.md +91 -0
  60. package/templates/_agents/commands/maintain.md +2 -3
  61. package/templates/_agents/commands/plan.md +4 -1
  62. package/templates/_agents/commands/review.md +1 -0
  63. package/templates/_agents/commands/source-sync-check.md +103 -0
  64. package/templates/_agents/commands/sync-hosts.md +79 -0
  65. package/templates/_agents/commands/test.md +4 -2
  66. package/templates/_agents/hooks/check-pairing-incremental.sh +20 -7
  67. package/templates/_agents/notes/runtime-env.md +1 -1
  68. package/templates/_agents/roles/implementer.md +2 -0
  69. package/templates/_agents/roles/independent-reviewer.md +2 -0
  70. package/templates/_agents/roles/ui-verifier.md +2 -0
  71. package/templates/_agents/rule-budgets.txt +1 -1
  72. package/templates/_agents/scripts/agg-delegations.cjs +39 -30
  73. package/templates/_agents/scripts/agg-delegations.test.mjs +32 -0
  74. package/templates/_agents/scripts/check-loop.mjs +763 -0
  75. package/templates/_agents/scripts/check-loop.sh +5 -808
  76. package/templates/_agents/scripts/check-loop.test.mjs +1102 -0
  77. package/templates/_agents/scripts/confirm-doc.mjs +228 -0
  78. package/templates/_agents/scripts/confirm-doc.test.mjs +321 -0
  79. package/templates/_agents/scripts/doctor.test.mjs +371 -0
  80. package/templates/_agents/scripts/ensure-board.mjs +30 -3
  81. package/templates/_agents/scripts/fill-intent.mjs +59 -0
  82. package/templates/_agents/scripts/fill-intent.test.mjs +73 -0
  83. package/templates/_agents/scripts/fill-plan.mjs +60 -0
  84. package/templates/_agents/scripts/fill-plan.test.mjs +67 -0
  85. package/templates/_agents/scripts/fill-spec.mjs +52 -0
  86. package/templates/_agents/scripts/fill-spec.test.mjs +61 -0
  87. package/templates/_agents/scripts/gate-checklist.mjs +194 -0
  88. package/templates/_agents/scripts/gate-checklist.test.mjs +204 -0
  89. package/templates/_agents/scripts/gen-wiki-board.mjs +38 -24
  90. package/templates/_agents/scripts/gen-wiki-board.test.mjs +18 -0
  91. package/templates/_agents/scripts/gen-workflow-index.mjs +4 -2
  92. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -3
  93. package/templates/_agents/scripts/kb-search.mjs +25 -9
  94. package/templates/_agents/scripts/kb-search.test.mjs +38 -8
  95. package/templates/_agents/scripts/source-sync-check.mjs +145 -0
  96. package/templates/_agents/scripts/source-sync-check.test.mjs +244 -0
  97. package/templates/_agents/scripts/verify-wiki-consistency.mjs +46 -23
  98. package/templates/_agents/scripts/verify.mjs +8 -10
  99. package/templates/_agents/scripts/verify.test.mjs +13 -1
  100. package/templates/_agents/scripts/wf-journal.mjs +158 -0
  101. package/templates/_agents/scripts/wf-journal.test.mjs +150 -0
  102. package/templates/_agents/scripts/workflow-board-server.mjs +73 -17
  103. package/templates/_agents/scripts/workflow-board-server.test.mjs +136 -0
  104. package/templates/_agents/scripts/workflow-enums.mjs +70 -0
  105. package/templates/_agents/scripts/workflow-enums.test.mjs +110 -0
  106. package/templates/_agents/scripts/workflows-check.mjs +260 -0
  107. package/templates/_agents/scripts/workflows-check.test.mjs +267 -0
  108. package/templates/_agents/workflow-enums.txt +19 -0
  109. package/templates/_agents/workflows/_TEMPLATE.md +75 -0
  110. package/templates/_agents/workflows/pipeline-closing.md +29 -0
  111. package/templates/_agents/workflows/source-sync-repair.md +30 -0
  112. package/templates/_agents/workflows/steps//347/244/272/344/276/213-/351/203/250/347/275/262/351/252/214/350/257/201.md +10 -0
  113. package/templates/_agents/workflows//347/244/272/344/276/213-/345/271/266/350/241/214/345/256/236/347/216/260/350/257/204/345/256/241.md +16 -0
  114. package/templates/_githooks/pre-commit +16 -1
  115. package/templates/workflow/README.md +1 -1
  116. package/templates/_agents/cache/kb-index.json +0 -1
  117. package/templates/_agents/scripts/check-loop.test.sh +0 -1042
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // wf-journal.test.mjs — run journal 测试(2026-09-26 orchestration-journal-human)
3
+ // 方法:纯函数(replayRun/planStages/latestRun)fixture 直测 + CLI add/status spawn 冒烟(临时 journal + 临时 workflows 目录)
4
+ // + 真实仓库 pipeline-closing 解析(human 形态行在位)。
5
+ // 判据:① schema 各字段与 attempt/run 语义 ② 末次生效 ③ 就绪/待定/中止分层 ④ 坏行容忍 ⑤ 9 列/human 列两代表全解析
6
+ // 用法:node templates/_agents/scripts/wf-journal.test.mjs(npm test 随跑)
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { replayRun, planStages, latestRun } from './wf-journal.mjs';
13
+ import { parseStages } from './workflows-check.mjs';
14
+
15
+ let pass = 0;
16
+ let fail = 0;
17
+ const check = (desc, cond, detail = '') => {
18
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
19
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
20
+ };
21
+
22
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
23
+ const JOURNAL_CLI = path.join(SCRIPT_DIR, 'wf-journal.mjs');
24
+
25
+ const mkline = (over = {}) => JSON.stringify({ ts: '2026-09-26T10:00:00Z', run: 'w@20260926-1000', wf: 'w', stage: 'a', status: 'pass', attempt: 1, ...over });
26
+ const STAGES = parseStages(`| id | after | role | step | task | files | accept | gate | human | retries |
27
+ |----|-------|------|------|------|-------|--------|------|-------|---------|
28
+ | a | — | implementer | — | 做 A | src/** | 过 | | | |
29
+ | ask | a | — | — | 用户确认 | — | 一句可以 | | 用户确认 | |
30
+ | b | ask | — | — | — | — | — | npm test | | |
31
+ | c | b | — | — | — | — | — | npm run build | | |
32
+ `) || [];
33
+
34
+ // ---- S1 replayRun:字段进 state、total 计数 ----
35
+ {
36
+ const r = replayRun([mkline(), mkline({ stage: 'ask', form: 'human', status: 'pass', note: '用户点头' })], 'w@20260926-1000');
37
+ check('S1 replayRun 记 2 条且 form/note 进 state',
38
+ r.total === 2 && r.state.a.status === 'pass' && r.state.ask.form === 'human' && r.state.ask.note === '用户点头',
39
+ JSON.stringify(r));
40
+ }
41
+
42
+ // ---- S2 末次生效 + attempt 取最大(fail→pass 重试)----
43
+ {
44
+ const r = replayRun([
45
+ mkline({ stage: 'b', status: 'fail', attempt: 1 }),
46
+ mkline({ stage: 'b', status: 'fail', attempt: 2 }),
47
+ mkline({ stage: 'b', status: 'pass', attempt: 3 }),
48
+ ], 'w@20260926-1000');
49
+ check('S2 末次状态生效 pass 且 attempt=3',
50
+ r.state.b.status === 'pass' && r.state.b.attempt === 3,
51
+ JSON.stringify(r.state.b));
52
+ }
53
+
54
+ // ---- S3 其他 run / 坏行不混入 ----
55
+ {
56
+ const r = replayRun([
57
+ mkline({ stage: 'a' }),
58
+ mkline({ stage: 'x', run: 'w@OTHER' }),
59
+ '{ broken json',
60
+ mkline({ stage: 'a', status: 'nope' }),
61
+ ], 'w@20260926-1000');
62
+ check('S3 异 run 过滤 + 坏 JSON/坏 status 计 bad 不进 state',
63
+ r.total === 1 && r.bad === 2 && !r.state.x,
64
+ JSON.stringify(r));
65
+ }
66
+
67
+ // ---- S4 latestRun:取该 wf 最后一条 run ----
68
+ {
69
+ const run = latestRun([mkline({ run: 'w@A' }), mkline({ run: 'w@B', stage: 'a' })], 'w');
70
+ check('S4 latestRun 取末条 w@B', run === 'w@B', String(run));
71
+ }
72
+
73
+ // ---- S5 planStages:空状态 → 第 0 层就绪;pass 后下层就绪;fail 下层待定 ----
74
+ {
75
+ const p0 = planStages(STAGES, {});
76
+ const p1 = planStages(STAGES, { a: { status: 'pass', attempt: 1 } });
77
+ const p2 = planStages(STAGES, { a: { status: 'pass', attempt: 1 }, ask: { status: 'fail', attempt: 1 } });
78
+ check('S5 分层:首层 a 就绪 → a 过后 ask 就绪 → ask fail 时 b/c 待定且 ask 重试就绪',
79
+ p0.ready.map((s) => s.id).join() === 'a'
80
+ && p1.ready.map((s) => s.id).join() === 'ask'
81
+ && p2.ready.some((s) => s.id === 'ask' && s.retry === 1)
82
+ && p2.pending.map((s) => s.id).join() === 'b,c',
83
+ JSON.stringify({ p0, p1, p2 }));
84
+ }
85
+
86
+ // ---- S6 planStages:blocked 标注中止 + 依赖中止提示 ----
87
+ {
88
+ const p = planStages(STAGES, { a: { status: 'pass', attempt: 1 }, ask: { status: 'blocked', attempt: 1, note: '用户驳回' } });
89
+ check('S6 blocked 进中止列且下游标依赖中止',
90
+ p.blocked.length === 1 && p.blocked[0].id === 'ask' && p.blocked[0].note === '用户驳回'
91
+ && p.pending.some((s) => s.id === 'b' && s.depBlocked),
92
+ JSON.stringify(p));
93
+ }
94
+
95
+ // ---- S7 CLI add 冒烟:attempt 自动计次 + run 复用 ----
96
+ {
97
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wfj-cli-'));
98
+ const j = path.join(tmp, 'j.jsonl');
99
+ const run = (args) => spawnSync(process.execPath, [JOURNAL_CLI, ...args], { encoding: 'utf8' });
100
+ const r1 = run(['add', '--wf', 'w', '--stage', 'a', '--status', 'pass', '--journal', j]);
101
+ const r2 = run(['add', '--wf', 'w', '--stage', 'a', '--status', 'fail', '--journal', j]);
102
+ const lines = fs.readFileSync(j, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
103
+ check('S7 add×2 attempt 自动 1→2 且 run 复用同 id',
104
+ r1.status === 0 && r2.status === 0 && lines.length === 2
105
+ && lines[0].attempt === 1 && lines[1].attempt === 2 && lines[0].run === lines[1].run,
106
+ JSON.stringify({ r1: r1.stdout + r1.stderr, r2: r2.stdout + r2.stderr, lines }));
107
+ fs.rmSync(tmp, { recursive: true, force: true });
108
+ }
109
+
110
+ // ---- S8 CLI status 冒烟:临时 workflows 目录 + 坏行容忍 ----
111
+ {
112
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wfj-st-'));
113
+ const wfRoot = path.join(tmp, 'workflows');
114
+ fs.mkdirSync(wfRoot, { recursive: true });
115
+ fs.writeFileSync(path.join(wfRoot, 'demo.md'),
116
+ '---\nname: demo\ndescription: d\n---\n\n' +
117
+ '| id | after | role | step | task | files | accept | gate | retries |\n' +
118
+ '|----|-------|------|------|------|-------|--------|------|---------|\n' +
119
+ '| a | — | — | — | — | — | — | npm test | |\n' +
120
+ '| b | a | — | — | — | — | — | npm run build | |\n');
121
+ const j = path.join(tmp, 'j.jsonl');
122
+ fs.writeFileSync(j, '{ broken\n' + mkline({ wf: 'demo', stage: 'a' }) + '\n');
123
+ const r = spawnSync(process.execPath, [JOURNAL_CLI, 'status', '--wf', 'demo', '--journal', j, '--wf-root', wfRoot], { encoding: 'utf8' });
124
+ check('S8 status:坏行告警不炸、a 已完成、b 就绪',
125
+ r.status === 0 && /坏行 1 条/.test(r.stderr) && /已完成(1):a/.test(r.stdout) && /就绪(1):b/.test(r.stdout),
126
+ JSON.stringify({ out: r.stdout, err: r.stderr }));
127
+ fs.rmSync(tmp, { recursive: true, force: true });
128
+ }
129
+
130
+ // ---- S9 真实仓库:pipeline-closing 解析含 human 形态 confirm 行;无 journal 时首层就绪 ----
131
+ {
132
+ const ROOT = path.resolve(SCRIPT_DIR, '..', '..', '..');
133
+ const wfFile = path.join(ROOT, '.agents', 'workflows', 'pipeline-closing.md');
134
+ if (!fs.existsSync(wfFile)) {
135
+ console.log('SKIP S9(非包源仓库,无 pipeline-closing)');
136
+ } else {
137
+ const stages = parseStages(fs.readFileSync(wfFile, 'utf8')) || [];
138
+ const confirm = stages.find((s) => s.id === 'confirm');
139
+ const j = path.join(os.tmpdir(), `wfj-real-${Date.now()}.jsonl`);
140
+ const r = spawnSync(process.execPath, [JOURNAL_CLI, 'status', '--wf', 'pipeline-closing', '--journal', j], { encoding: 'utf8', cwd: ROOT });
141
+ check('S9 真实 pipeline-closing:confirm 为 human 形态且无 journal 时 author 首层就绪',
142
+ !!confirm && confirm.human && !confirm.role && stages.length >= 10
143
+ && r.status === 0 && /就绪(第 0 层.*author/.test(r.stdout),
144
+ JSON.stringify({ confirm, out: r.stdout }));
145
+ fs.rmSync(j, { force: true });
146
+ }
147
+ }
148
+
149
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
150
+ process.exit(fail ? 1 : 0);
@@ -8,7 +8,8 @@ import http from 'node:http';
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { execFile } from 'node:child_process';
11
+ import { execFile, spawnSync } from 'node:child_process';
12
+ import { ENUMS } from './workflow-enums.mjs';
12
13
 
13
14
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
14
15
  const WORKFLOW = path.join(ROOT, 'workflow');
@@ -37,9 +38,10 @@ function parseDoc(text) {
37
38
  return { meta, title };
38
39
  }
39
40
 
40
- // ---- 验收标准 checkbox 统计:仅「## 验收标准」节内(至下一个 ## 标题),无该节返回 null ----
41
+ // ---- 验收标准 checkbox 统计:仅「验收标准」节内(至下一个 ## 标题),无该节返回 null ----
42
+ // 节匹配对齐 check-loop 检查 8 口径(^\s*##\s[^#]*验收标准,2026-09-27 board-kb-p1——「## 三、验收标准」类标题此前识别不到)
41
43
  function parseAcceptance(text) {
42
- const h = text.match(/^## 验收标准.*$/m);
44
+ const h = text.match(/^\s*##\s[^#]*验收标准.*$/m);
43
45
  if (!h) return null;
44
46
  const rest = text.slice(h.index + h[0].length);
45
47
  const next = rest.match(/^## /m);
@@ -49,19 +51,20 @@ function parseAcceptance(text) {
49
51
  const done = (section.match(/^\s*[-*] \[[xX]\]/gm) || []).length;
50
52
  return { done, total };
51
53
  }
54
+ export { parseAcceptance };
52
55
 
53
56
  // ---- 配对断裂检测:按 slug 聚合同族(同名 intent/incident/plan/spec),异常卡附 alerts ----
54
- // 规则(口径对齐 check-loop.sh 的硬断档 + 状态枚举,看板为预警层、不阻断):
57
+ // 规则(口径对齐 check-loop.sh 的硬断档 + 状态枚举,看板为预警层、不阻断;枚举读单源 workflow-enums.txt):
55
58
  // 孤儿 spec / 孤儿 plan(无同名 intent·incident 入口);入口 done 但 plan 未终态(看板启发式);
56
59
  // 入口缺 plan(intent 一律要求;incident 需非 legacy 且级别 L1/L2/L3);L2/L3 入口缺同名 spec;
57
60
  // spec L3 确认三件缺失(确认结果 / 确认时间 / 正文独立复核行);
58
- // 状态不在枚举内或缺失(intent·spec·plan: approved/done/superseded/cancelled;incident: open/fixed/closed)
59
- const PLAN_TERMINAL = ['done', 'superseded', 'cancelled'];
61
+ // 状态不在枚举内或缺失(doc=confirmed 集;incident=all 集)
62
+ const PLAN_TERMINAL = ENUMS['doc.status.terminal'];
60
63
  const STATUS_ENUM = {
61
- intents: ['approved', 'done', 'superseded', 'cancelled'],
62
- specs: ['approved', 'done', 'superseded', 'cancelled'],
63
- plans: ['approved', 'done', 'superseded', 'cancelled'],
64
- incidents: ['open', 'fixed', 'closed'],
64
+ intents: ENUMS['doc.status.confirmed'],
65
+ specs: ENUMS['doc.status.confirmed'],
66
+ plans: ENUMS['doc.status.confirmed'],
67
+ incidents: ENUMS['incident.status.all'],
65
68
  };
66
69
  const NO_STATUS = '(未填)';
67
70
  const L3_REVIEW = /^- 独立复核:[ \t]*[^<\s]/m; // 正文独立复核行须有实质内容(对照 check-loop 锚定)
@@ -94,7 +97,7 @@ function detectAlerts(cards) {
94
97
  alerts.push('入口缺 plan:无同名 plan');
95
98
  if (isEntry && !legacyIncident && ['L2', 'L3'].includes(c.level) && !types.has('specs'))
96
99
  alerts.push(`${c.level} 入口缺同名 spec`);
97
- if (c.type === 'specs' && c.level === 'L3' && !['superseded', 'cancelled'].includes(c.status)) {
100
+ if (c.type === 'specs' && c.level === 'L3' && !ENUMS['doc.status.abandoned'].includes(c.status)) {
98
101
  if (c.confirm !== 'approved') alerts.push(`L3 确认缺失:确认结果须为 approved(现 ${c.confirm || '缺失'})`);
99
102
  if (!c.confirmTime) alerts.push('L3 确认缺失:须记录确认时间');
100
103
  if (!c.hasReview) alerts.push('L3 复核缺失:须记录新会话独立复核结论');
@@ -156,7 +159,12 @@ async function scanBoard() {
156
159
  }
157
160
  detectAlerts(cards);
158
161
  cards.sort((a, b) => b.date.localeCompare(a.date) || a.type.localeCompare(b.type));
159
- return { root: ROOT, pid: process.pid, startedAt: STARTED_AT, counts, cards };
162
+ const loop = loopHardBlocks(); // hard-block 全局告警(单源 check-loop;null = 校验不可用)
163
+ return {
164
+ root: ROOT, pid: process.pid, startedAt: STARTED_AT, counts, cards,
165
+ loopHardBlocks: loop.blocks, loopNote: loop.note || undefined,
166
+ activeStatuses: [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']], // 枚举单源贯通前端(p2-batch2)
167
+ };
160
168
  }
161
169
 
162
170
  // ---- 路径白名单:resolve 后必须仍在 workflow/ 内 ----
@@ -201,13 +209,54 @@ setInterval(() => { for (const res of sseClients) { if (res.destroyed) sseClient
201
209
  const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
202
210
 
203
211
  function sendJson(res, code, obj) {
204
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
212
+ // X-Frame-Options(p2-batch2):本地只读服务拒绝被外站 iframe 嵌套(配合 Host 校验收敛攻击面)
213
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'X-Frame-Options': 'DENY' });
205
214
  res.end(JSON.stringify(obj));
206
215
  }
207
216
 
217
+ // ---- hard-block 告警单源(2026-09-27 board-kb-p1)----
218
+ // 看板不再自建与 check-loop 平行的 hard 规则(此前四类缺失:回路断档/验收未对账/确认未对账/确认内容漂移,
219
+ // test.md「双跑断言」曾必失败)——scanBoard 时 spawn check-loop.mjs,解析其 stderr hard-block 段为全局告警数组:
220
+ // check-loop 未来新增 hard 项看板自动跟随,口径永不分叉。输出 banner 为稳定契约(check-loop 头注释明示
221
+ // doctor/pre-push 按输出消费),本解析由双跑断言测试兜底格式漂移。
222
+ export function parseLoopHardBlocks(stderrText) {
223
+ const text = String(stderrText || '');
224
+ const start = text.indexOf('HARD-BLOCK:');
225
+ if (start === -1) return [];
226
+ let seg = text.slice(start);
227
+ const warnIdx = seg.indexOf('WARN');
228
+ if (warnIdx > 0) seg = seg.slice(0, warnIdx);
229
+ return seg.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.startsWith('- ['));
230
+ }
231
+
232
+ function loopHardBlocks() {
233
+ // TTL 缓存(复核 P2-2 rider):check-loop 独跑实测 ~10.5s 且 spawnSync 阻塞事件循环——搜索击键/SSE
234
+ // 连发场景下每请求全跑不可用。60s 内复用上次结果(loopHardBlocks 是预警层非门禁,陈旧 60s 可接受;
235
+ // 前端 loopNote 均如实展示)。进程级缓存,无失效盲区风险(比照 kb 两元组口径弱化声明:预警层容忍)。
236
+ const NOW = Date.now();
237
+ if (loopCache.at && NOW - loopCache.at < 60_000) return loopCache.v;
238
+ const v = (() => {
239
+ try {
240
+ const r = spawnSync(process.execPath, [path.join(ROOT, '.agents', 'scripts', 'check-loop.mjs')], { cwd: ROOT, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
241
+ if (r.error) return { blocks: null, note: `check-loop 不可执行:${r.error.message}` };
242
+ return { blocks: parseLoopHardBlocks(r.stderr || ''), note: '' };
243
+ } catch (e) {
244
+ return { blocks: null, note: e.message };
245
+ }
246
+ })();
247
+ loopCache.at = NOW; loopCache.v = v;
248
+ return v;
249
+ }
250
+ const loopCache = { at: 0, v: null };
251
+
208
252
  const server = http.createServer(async (req, res) => {
209
- const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
210
253
  try {
254
+ // Host 校验(DNS rebinding 缓解,2026-09-27 board-kb-p1):本地只读服务只认本机 Host
255
+ const host = String(req.headers.host || '');
256
+ if (host !== `127.0.0.1:${PORT}` && host !== `localhost:${PORT}`) {
257
+ return sendJson(res, 403, { error: `Host 不受信任(仅接受 127.0.0.1:${PORT} / localhost:${PORT})` });
258
+ }
259
+ const url = new URL(req.url, `http://127.0.0.1:${PORT}`); // 畸形请求行抛错 → 400(P1-3:原在 try 外,单包可杀进程)
211
260
  if (url.pathname === '/api/events') {
212
261
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
213
262
  res.write('retry: 3000\n\n');
@@ -237,6 +286,9 @@ const server = http.createServer(async (req, res) => {
237
286
  res.writeHead(200, { 'Content-Type': MIME[path.extname(abs)] || 'application/octet-stream' });
238
287
  fs.createReadStream(abs).pipe(res);
239
288
  } catch (e) {
289
+ if (e && e.code === 'ERR_INVALID_URL') {
290
+ return sendJson(res, 400, { error: `请求行/URL 非法:${e.message}` }); // 畸形请求不杀进程(P1-3);只认 URL 错误码——其余 TypeError 是服务端缺陷,如实 500(复核 P2-1)
291
+ }
240
292
  sendJson(res, 500, { error: e.message });
241
293
  }
242
294
  });
@@ -247,6 +299,10 @@ server.on('error', (e) => {
247
299
  process.exit(1);
248
300
  });
249
301
 
250
- server.listen(PORT, '127.0.0.1', () => {
251
- console.log(`workflow 看板: http://127.0.0.1:${PORT} (Ctrl+C 停止;只读 workflow/,不写任何文件)`);
252
- });
302
+ // isMain 守卫(board-kb-p1):parseLoopHardBlocks 可被测试 import(import 不 listen)
303
+ const isMain = process.argv[1] && process.argv[1].endsWith('workflow-board-server.mjs');
304
+ if (isMain) {
305
+ server.listen(PORT, '127.0.0.1', () => {
306
+ console.log(`workflow 看板: http://127.0.0.1:${PORT} (Ctrl+C 停止;只读 workflow/,不写任何文件)`);
307
+ });
308
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // workflow-board-server 的纯函数测试(2026-09-27 board-kb-p1)
3
+ // 核心 = 「双跑断言」的机器化:test.md 要求「涉及看板告警规则时,用同批 workflow fixture 双跑 check-loop
4
+ // 与看板 /api/board,断言 hard-block 集合被看板告警覆盖(防口径分叉)」——此前是纯手工步骤且必失败
5
+ // (四类 hard-block 看板无对应项)。告警单源化后看板直接消费 check-loop 输出,本测试把双跑断言固化为:
6
+ // check-loop fixture 输出 → parseLoopHardBlocks → 断言四类 hard-block 全覆盖。
7
+ // 断言:①parseLoopHardBlocks 合成样本四类齐;②真 fixture spawn check-loop → 解析覆盖四类(双跑断言);
8
+ // ③空/无 hard-block → 空数组;④parseAcceptance 节匹配与 check-loop 检查 8 同口径。
9
+ // 用法:node .agents/scripts/workflow-board-server.test.mjs(import 不 listen——server 有 isMain 守卫)
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { parseLoopHardBlocks, parseAcceptance } from './workflow-board-server.mjs';
16
+ import { computeFingerprint } from './confirm-doc.mjs';
17
+
18
+ let pass = 0;
19
+ let fail = 0;
20
+ const check = (desc, cond, detail = '') => {
21
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
22
+ else { fail++; console.log(`FAIL ${desc}${detail ? `\n${detail}` : ''}`); }
23
+ };
24
+
25
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
26
+ const CHECK_LOOP = path.join(SCRIPT_DIR, 'check-loop.mjs');
27
+
28
+ // ---- ①合成样本:四类 hard-block 各一 → 全解析 ----
29
+ {
30
+ const sample = [
31
+ '闭环骨架断档(check-loop.sh)— HARD-BLOCK:',
32
+ '',
33
+ '- [配对断裂] intent 缺 plan:x.md',
34
+ '- [回路断档] inc 选「是」但未指明 intent 文件路径',
35
+ '- [验收未对账] done intent 缺「## 验收标准」节(勾验无从核对):x.md',
36
+ '- [确认未对账] x.md 状态 done 无用户确认记录',
37
+ '- [确认内容漂移] x.md done 后内容与确认台账不符',
38
+ '',
39
+ 'WARN(advisory,不阻断):',
40
+ '',
41
+ '- [WARN 状态未确认] plan 必须...',
42
+ ].join('\n');
43
+ const parsed = parseLoopHardBlocks(sample);
44
+ check('①合成样本解析 5 行 hard-block(WARN 段不混入)', parsed.length === 5, JSON.stringify(parsed));
45
+ check('①四类目标 hard-block 全覆盖(回路断档/验收未对账/确认未对账/确认内容漂移)',
46
+ ['回路断档', '验收未对账', '确认未对账', '确认内容漂移'].every((k) => parsed.some((l) => l.includes(k))),
47
+ JSON.stringify(parsed));
48
+ }
49
+
50
+ // ---- ②双跑(真 fixture):spawn check-loop → parseLoopHardBlocks 覆盖四类 ----
51
+ // **检查 8 换锚后的前置条件(2026-09-28 check8-git-anchor)**:检查 8 的生效日锚已从文件名前缀
52
+ // 改为「文件首次加入 git 的日期」,故「验收未对账」这条 hard 的产出**需要 git 首次加入日期可判定**;
53
+ // 非 git 仓会走存量口径、不再产出该 hard(有意语义,见 spec)。因此本场景需为**真 git 仓**——
54
+ // 这不是放宽断言,而是补上使原断言(四类 hard-block 全覆盖)成立的前置;**断言强度与文案均未变**。
55
+ {
56
+ const T = fs.mkdtempSync(path.join(os.tmpdir(), 'board-loop-test-'));
57
+ for (const s of ['intents', 'specs', 'plans', 'incidents']) fs.mkdirSync(path.join(T, 'workflow', s), { recursive: true });
58
+ fs.mkdirSync(path.join(T, '.agents'), { recursive: true });
59
+ fs.writeFileSync(path.join(T, '.agents', 'workflow-enums.txt'), [
60
+ 'doc.status.all=draft approved done superseded cancelled',
61
+ 'doc.status.confirmed=approved done superseded cancelled',
62
+ 'doc.status.active=draft approved',
63
+ 'doc.status.terminal=done superseded cancelled',
64
+ 'doc.status.abandoned=superseded cancelled',
65
+ 'incident.status.all=open fixed closed',
66
+ 'incident.status.active=open',
67
+ 'level.all=L0 L1 L2 L3',
68
+ ].join('\n') + '\n');
69
+ const w = (rel, c) => fs.writeFileSync(path.join(T, rel), c);
70
+ // 回路断档:incident 三件套选「是」但引用 intent 不存在
71
+ w('workflow/incidents/2026-09-27-loop.md', '---\n状态: open\n级别: L1\n发现: 2026-09-27\n模块: pipeline\n---\n# INCIDENT — loop\n\n## 复盘三件套\n\n1. 结构性修复\n - 是否需要新 intent:\n - 是 → ../intents/2026-09-12-ghost.md\n2. 防复发验证\n3. 规范条目\n');
72
+ // 验收未对账:done intent 无验收标准节(锚 = git 首次加入日期 → 见上)
73
+ w('workflow/intents/2026-09-27-acc.md', '---\n状态: done\n级别: L1\n日期: 2026-09-27\n模块: pipeline\n---\n# INTENT — acc\n');
74
+ // 确认未对账:done 无指纹无台账
75
+ w('workflow/plans/2026-09-27-cg.md', '---\n状态: done\n级别: L1\n日期: 2026-09-27\n---\n# PLAN — cg\n');
76
+ // 确认内容漂移:done + 台账 prev + 落盘后篡改
77
+ {
78
+ const body = '\n## 验收标准(可测试)\n- [x] 用例通过(证据:fixture)\n';
79
+ const pre = `---\n状态: approved\n级别: L1\n日期: 2026-09-28\n确认指纹: ${'a'.repeat(16)}\n---\n# INTENT — drift\n${body}`;
80
+ const fp = computeFingerprint(pre);
81
+ w('workflow/intents/2026-09-28-drift.md', `---\n状态: done\n级别: L1\n日期: 2026-09-28\n确认指纹: ${fp.slice(0, 16)}\n---\n# INTENT — drift\n${body}确认后篡改行\n`);
82
+ fs.writeFileSync(path.join(T, '.agents', 'confirmations.jsonl'),
83
+ JSON.stringify({ ts: '2026-09-28T02:00:00.000Z', doc: 'workflow/intents/2026-09-28-drift.md', stage: 'done', fingerprint: fp, prev: 'approved', source: 'tty' }) + '\n');
84
+ }
85
+ // 配对齐 plan(避免无关阻断干扰可读性——配对断裂也会在 hard 段,不影响四类断言)
86
+ w('workflow/plans/2026-09-27-loop.md', '---\n状态: draft\n级别: L1\n---\n# PLAN — loop\n');
87
+ w('workflow/plans/2026-09-27-acc.md', '---\n状态: draft\n级别: L1\n---\n# PLAN — acc\n');
88
+
89
+ // 真 git 仓(使检查 8 的 git 锚可判定)——本地提交,不入任何远端
90
+ const G = process.platform === 'win32' ? 'git.exe' : 'git';
91
+ spawnSync(G, ['init', '-q'], { cwd: T });
92
+ spawnSync(G, ['add', '-A'], { cwd: T });
93
+ spawnSync(G, ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture'], { cwd: T });
94
+
95
+ const r = spawnSync(process.execPath, [CHECK_LOOP], { cwd: T, encoding: 'utf8', env: { ...process.env, CHECK_LOOP_ROOT: T } });
96
+ const parsed = parseLoopHardBlocks(r.stderr || '');
97
+ check('②双跑:check-loop fixture exit 1(确有 hard-block)', r.status === 1, `exit=${r.status}\n${r.stderr}`);
98
+ check('②双跑断言:四类 hard-block 均被看板告警解析覆盖',
99
+ ['回路断档', '验收未对账', '确认未对账', '确认内容漂移'].every((k) => parsed.some((l) => l.includes(k))),
100
+ JSON.stringify(parsed));
101
+ fs.rmSync(T, { recursive: true, force: true });
102
+ }
103
+
104
+ // ---- ③空/无 hard-block → 空数组 ----
105
+ {
106
+ check('③纯 WARN 输出 → 空数组', parseLoopHardBlocks('check-loop.sh WARN(advisory,不阻断):\n\n- [WARN x]\n').length === 0);
107
+ check('③空输入 → 空数组', parseLoopHardBlocks('').length === 0);
108
+ check('③exit 0 场景(仅 WARN banner)→ 空数组', parseLoopHardBlocks('check-loop.sh WARN(advisory,不阻断):\n\n- [WARN 引用断档] x\n').length === 0);
109
+ }
110
+
111
+ // ---- ④parseAcceptance 与 check-loop 检查 8 节匹配同口径 ----
112
+ {
113
+ const std = '---\n状态: done\n---\n# I\n\n## 验收标准(可测试)\n- [x] 甲(证据:e)\n- [ ] 乙\n';
114
+ const numbered = '---\n状态: done\n---\n# I\n\n## 三、验收标准\n- [x] 甲(证据:e)\n- [ ] 乙\n';
115
+ check('④标准标题解析 1/2', parseAcceptance(std)?.done === 1 && parseAcceptance(std)?.total === 2, JSON.stringify(parseAcceptance(std)));
116
+ check('④带序号标题(## 三、验收标准)此前识别不到、现在同口径', parseAcceptance(numbered)?.total === 2, JSON.stringify(parseAcceptance(numbered)));
117
+ check('④无验收节 → null', parseAcceptance('---\n状态: done\n---\n# I\n') === null);
118
+ }
119
+
120
+ // ---- ⑤XSS 剥离口径(p2-batch2;复核 P2-3 备注)----
121
+ // 注意:strip 与 board/index.html loadDoc 的剥离正则是【同款拷贝、非同源】——改 loadDoc 正则时本场景
122
+ // 须同步(防回归能力受限为已知取舍:抽共享模块需前端打包链路,暂不做)。
123
+ // activeStatuses 载荷断言未入本套件(server 侧 scanBoard 非纯函数不可直调)——
124
+ // 以 /api/board 手工探活留证(复核已实测枚举贯通 + X-Frame-Options DENY)。
125
+ {
126
+ const strip = (s) => s.replace(/<[^>]*>/g, '');
127
+ const evil = '正常文字 <img src=x onerror=alert(1)> 更多 <script>alert(2)</script> 结束';
128
+ check('⑤剥标签:img/script 标签清零,正文文字保留',
129
+ !/<(img|script)/.test(strip(evil)) && strip(evil).includes('正常文字') && strip(evil).includes('结束'),
130
+ strip(evil));
131
+ const md = '## 标题\n\n- 列表 **粗体** `code`\n\n[链接](intents/x.md)';
132
+ check('⑤markdown 语法不受剥标签影响', strip(md) === md);
133
+ }
134
+
135
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
136
+ process.exit(fail ? 1 : 0);
@@ -0,0 +1,70 @@
1
+ // workflow-enums 读取器——.agents/workflow-enums.txt 单源(2026-09-25 enum-single-source)
2
+ // 消费方:check-loop.mjs / workflow-board-server.mjs / kb-search.mjs / gen-workflow-index.mjs /
3
+ // fill-{intent,plan,spec}.mjs(均经本读取器)。(2026-09-26 check-loop sh→node 迁移后,
4
+ // 原先的 sh sed 消费方已退役——全部消费方统一经本读取器,无跨语言读取口径;S11 测试仍守
5
+ // 「空格分隔 + # 注释」格式对简单工具保持可解析)
6
+ // fail-loud 无 fallback:kit 自带该文件,缺失/坏数据属安装破损——直接抛错崩得可见,
7
+ // doctor §2 布局门同时把关文件存在性。禁止在任何消费方内再抄一份枚举字面量。
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const ENUMS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'workflow-enums.txt');
13
+
14
+ // parseEnums(text):`key=a b` 行 → { key: ['a','b'] };非法行/重复键/重复值抛错(单源纪律)
15
+ export function parseEnums(text) {
16
+ const out = {};
17
+ for (const line of String(text).split(/\r?\n/)) {
18
+ if (!line || line.startsWith('#')) continue;
19
+ const m = line.match(/^([a-z][a-z0-9.]*)=(\S.*)$/);
20
+ if (!m) throw new Error('workflow-enums 行格式非法(须 key=value value): ' + line);
21
+ if (m[1] in out) throw new Error('workflow-enums 键重复: ' + m[1]);
22
+ const vals = m[2].trim().split(/\s+/);
23
+ if (new Set(vals).size !== vals.length) throw new Error('workflow-enums 值重复: ' + line);
24
+ out[m[1]] = vals;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ const NEED_KEYS = [
30
+ 'doc.status.all', 'doc.status.confirmed', 'doc.status.active', 'doc.status.terminal', 'doc.status.abandoned',
31
+ 'incident.status.all', 'incident.status.active', 'level.all',
32
+ ];
33
+
34
+ // validateEnums(enums):跨键不变量(子集关系 + 活跃/终态互斥 + 关键态在位)——坏数据尽早抛错
35
+ export function validateEnums(e) {
36
+ for (const k of NEED_KEYS) {
37
+ if (!Array.isArray(e[k]) || !e[k].length) throw new Error('workflow-enums 缺键或空值: ' + k);
38
+ }
39
+ const sub = (a, b) => {
40
+ for (const v of e[a]) if (!e[b].includes(v)) throw new Error(`workflow-enums 不变量破: ${a} 含 ${b} 外的值 ${v}`);
41
+ };
42
+ sub('doc.status.confirmed', 'doc.status.all');
43
+ sub('doc.status.active', 'doc.status.all');
44
+ sub('doc.status.terminal', 'doc.status.all');
45
+ sub('doc.status.abandoned', 'doc.status.all');
46
+ sub('doc.status.abandoned', 'doc.status.terminal');
47
+ sub('incident.status.active', 'incident.status.all');
48
+ if (e['doc.status.active'].some((v) => e['doc.status.terminal'].includes(v))) {
49
+ throw new Error('workflow-enums 不变量破: doc.status.active 与 doc.status.terminal 重叠');
50
+ }
51
+ if (!e['doc.status.confirmed'].includes('approved') || !e['doc.status.terminal'].includes('done')) {
52
+ throw new Error('workflow-enums 不变量破: approved 须在 confirmed、done 须在 terminal(确认/关单语义锚点)');
53
+ }
54
+ return e;
55
+ }
56
+
57
+ // loadEnums(file = 单源标准位置):读文件 → parse → validate;缺文件抛错(fail-loud)
58
+ export function loadEnums(file = ENUMS_FILE) {
59
+ let text;
60
+ try {
61
+ text = fs.readFileSync(file, 'utf8');
62
+ } catch (err) {
63
+ throw new Error('workflow-enums 单源文件不可读(kit 自带,跑 flow-kit sync 恢复): ' + file + ' — ' + err.message);
64
+ }
65
+ return validateEnums(parseEnums(text));
66
+ }
67
+
68
+ // 模块级常量:import 即加载即校验(标准位置)
69
+ export const ENUMS = loadEnums();
70
+ export default ENUMS;
@@ -0,0 +1,110 @@
1
+ // workflow-enums.test.mjs — 枚举单源读取器测试(2026-09-25 enum-single-source)
2
+ // 判据:① 真实单源文件解析 + 不变量通过 ② 坏数据(坏行/重复键/重复值/缺键/子集破/重叠)fail-loud
3
+ // ③ 缺文件 fail-loud ④ sh 消费方同口径(sed 可取到全部 8 键)
4
+ // 用法:node templates/_agents/scripts/workflow-enums.test.mjs(npm test 随跑)
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { execSync } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { parseEnums, validateEnums, loadEnums, ENUMS } from './workflow-enums.mjs';
10
+
11
+ let pass = 0;
12
+ let fail = 0;
13
+ const check = (desc, cond, detail = '') => {
14
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
15
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
16
+ };
17
+ const throwsWith = (fn, kw) => {
18
+ try { fn(); return false; } catch (e) { return String(e.message).includes(kw); }
19
+ };
20
+
21
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
22
+ const ENUMS_TXT = path.join(SCRIPT_DIR, '..', 'workflow-enums.txt');
23
+
24
+ // ---- S1 模块级 ENUMS 即加载即校验(真实文件),8 键齐 ----
25
+ {
26
+ const keys = Object.keys(ENUMS).sort();
27
+ check('S1 真实单源文件加载成功且 8 键齐',
28
+ keys.length === 8 && keys.join(',') === 'doc.status.abandoned,doc.status.active,doc.status.all,doc.status.confirmed,doc.status.terminal,incident.status.active,incident.status.all,level.all',
29
+ JSON.stringify(keys));
30
+ }
31
+
32
+ // ---- S2 真实文件关键值口径(check-loop/看板/检索的行为锚点,值变更须连本测试一起改)----
33
+ {
34
+ check('S2 关键集值口径:confirmed 不含 draft、active=draft+approved、terminal 含 done、abandoned ⊆ terminal',
35
+ !ENUMS['doc.status.confirmed'].includes('draft')
36
+ && ENUMS['doc.status.active'].join('+') === 'draft+approved'
37
+ && ENUMS['doc.status.terminal'].includes('done')
38
+ && ENUMS['doc.status.abandoned'].every((v) => ENUMS['doc.status.terminal'].includes(v))
39
+ && ENUMS['level.all'].length === 4,
40
+ JSON.stringify(ENUMS));
41
+ }
42
+
43
+ // ---- S3 坏行 fail-loud ----
44
+ {
45
+ check('S3 非法行抛错', throwsWith(() => parseEnums('乱行 no-equals-sign'), '行格式非法'));
46
+ }
47
+
48
+ // ---- S4 重复键 fail-loud ----
49
+ {
50
+ check('S4 重复键抛错', throwsWith(() => parseEnums('a.b=x\na.b=y'), '键重复'));
51
+ }
52
+
53
+ // ---- S5 重复值 fail-loud ----
54
+ {
55
+ check('S5 重复值抛错', throwsWith(() => parseEnums('a.b=x x'), '值重复'));
56
+ }
57
+
58
+ // ---- S6 缺键 fail-loud(validateEnums)----
59
+ {
60
+ check('S6 缺键抛错', throwsWith(() => validateEnums({ 'level.all': ['L0'] }), '缺键'));
61
+ }
62
+
63
+ // ---- S7 子集破 fail-loud ----
64
+ {
65
+ const bad = {
66
+ 'doc.status.all': ['draft', 'approved', 'done'], 'doc.status.confirmed': ['approved', 'done', 'ghost'],
67
+ 'doc.status.active': ['draft'], 'doc.status.terminal': ['done'], 'doc.status.abandoned': ['cancelled'],
68
+ 'incident.status.all': ['open'], 'incident.status.active': ['open'], 'level.all': ['L0'],
69
+ };
70
+ check('S7 子集不变量破抛错', throwsWith(() => validateEnums(bad), '不变量破'));
71
+ }
72
+
73
+ // ---- S8 活跃/终态重叠 fail-loud ----
74
+ {
75
+ const bad = Object.fromEntries(Object.entries(ENUMS).map(([k]) => [k, [...ENUMS[k]]]));
76
+ bad['doc.status.terminal'] = [...bad['doc.status.terminal'], 'draft'];
77
+ check('S8 active 与 terminal 重叠抛错', throwsWith(() => validateEnums(bad), '重叠'));
78
+ }
79
+
80
+ // ---- S9 缺文件 fail-loud(loadEnums)----
81
+ {
82
+ check('S9 单源文件缺失抛错(fail-loud 无 fallback)',
83
+ throwsWith(() => loadEnums(path.join(SCRIPT_DIR, '..', 'no-such-enums.txt')), '不可读'));
84
+ }
85
+
86
+ // ---- S10 注释行与空行忽略 ----
87
+ {
88
+ const e = parseEnums('# 注释\n\nlevel.all=L0 L1\n');
89
+ check('S10 注释与空行忽略(仅 1 键)', Object.keys(e).length === 1 && e['level.all'].join('+') === 'L0+L1');
90
+ }
91
+
92
+ // ---- S11 sh 消费方同口径:check-loop.sh 的 sed 取法能取到全部 8 键 ----
93
+ {
94
+ const key = 'doc.status.confirmed';
95
+ const val = execSync(`sh -c "sed -n 's/^${key}=//p' '${ENUMS_TXT}' | head -n 1"`, { encoding: 'utf8' }).trim();
96
+ check('S11 sh sed 取键与 node 读取同值(跨语言单源口径)',
97
+ val === ENUMS[key].join(' '), `sh=[${val}] node=[${ENUMS[key].join(' ')}]`);
98
+ }
99
+
100
+ // ---- S12 CRLF 行尾容忍(autocrlf 检出坑:rule-budgets.txt 同约定;node 侧 split(/\r?\n/) 天然免疫,显式锁定)----
101
+ {
102
+ const crlf = 'doc.status.all=draft approved done superseded cancelled\r\nlevel.all=L0 L1 L2 L3\r\n';
103
+ const e = parseEnums(crlf);
104
+ check('S12 CRLF 行尾不残留 \\r(值尾无回车)',
105
+ e['level.all'].join('+') === 'L0+L1+L2+L3' && !e['doc.status.all'].some((v) => v.includes('\r')),
106
+ JSON.stringify(e));
107
+ }
108
+
109
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
110
+ process.exit(fail ? 1 : 0);