agentic-flow-kit 1.7.0 → 1.7.16

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 (162) hide show
  1. package/README.md +81 -9
  2. package/modules/gates/README.md +1 -0
  3. package/modules/gates/test-protect/README.md +23 -0
  4. package/modules/gates/test-protect/check-test-protect.mjs +64 -0
  5. package/modules/gates/test-protect/local-pre-commit.line +1 -0
  6. package/modules/hosts/README.md +57 -0
  7. package/modules/hosts/claude/commands/wf-build.md +5 -24
  8. package/modules/hosts/claude/commands/wf-deploy.md +1 -0
  9. package/modules/hosts/claude/commands/wf-design.md +7 -6
  10. package/modules/hosts/claude/commands/wf-gate-checklist.md +1 -3
  11. package/modules/hosts/claude/commands/wf-new-task.md +42 -21
  12. package/modules/hosts/claude/commands/wf-plan.md +1 -1
  13. package/modules/hosts/claude/commands/wf-review.md +1 -1
  14. package/modules/hosts/claude/commands/wf-sync-hosts.md +8 -16
  15. package/modules/hosts/claude/commands/wf-test.md +5 -4
  16. package/modules/hosts/codex/commands/wf-build.md +5 -24
  17. package/modules/hosts/codex/commands/wf-deploy.md +1 -0
  18. package/modules/hosts/codex/commands/wf-design.md +7 -6
  19. package/modules/hosts/codex/commands/wf-gate-checklist.md +1 -3
  20. package/modules/hosts/codex/commands/wf-new-task.md +42 -21
  21. package/modules/hosts/codex/commands/wf-plan.md +1 -1
  22. package/modules/hosts/codex/commands/wf-review.md +1 -1
  23. package/modules/hosts/codex/commands/wf-sync-hosts.md +8 -16
  24. package/modules/hosts/codex/commands/wf-test.md +5 -4
  25. package/modules/hosts/cursor/commands/wf-build.md +5 -24
  26. package/modules/hosts/cursor/commands/wf-deploy.md +1 -0
  27. package/modules/hosts/cursor/commands/wf-design.md +7 -6
  28. package/modules/hosts/cursor/commands/wf-gate-checklist.md +1 -3
  29. package/modules/hosts/cursor/commands/wf-new-task.md +42 -21
  30. package/modules/hosts/cursor/commands/wf-plan.md +1 -1
  31. package/modules/hosts/cursor/commands/wf-review.md +1 -1
  32. package/modules/hosts/cursor/commands/wf-sync-hosts.md +8 -16
  33. package/modules/hosts/cursor/commands/wf-test.md +5 -4
  34. package/modules/hosts/opencode/commands/wf-build.md +5 -24
  35. package/modules/hosts/opencode/commands/wf-deploy.md +1 -0
  36. package/modules/hosts/opencode/commands/wf-design.md +7 -6
  37. package/modules/hosts/opencode/commands/wf-gate-checklist.md +1 -3
  38. package/modules/hosts/opencode/commands/wf-new-task.md +42 -21
  39. package/modules/hosts/opencode/commands/wf-plan.md +1 -1
  40. package/modules/hosts/opencode/commands/wf-review.md +1 -1
  41. package/modules/hosts/opencode/commands/wf-sync-hosts.md +8 -16
  42. package/modules/hosts/opencode/commands/wf-test.md +5 -4
  43. package/modules/hosts/trae/commands/wf-build.md +5 -24
  44. package/modules/hosts/trae/commands/wf-deploy.md +1 -0
  45. package/modules/hosts/trae/commands/wf-design.md +7 -6
  46. package/modules/hosts/trae/commands/wf-gate-checklist.md +1 -3
  47. package/modules/hosts/trae/commands/wf-new-task.md +42 -21
  48. package/modules/hosts/trae/commands/wf-plan.md +1 -1
  49. package/modules/hosts/trae/commands/wf-review.md +1 -1
  50. package/modules/hosts/trae/commands/wf-sync-hosts.md +8 -16
  51. package/modules/hosts/trae/commands/wf-test.md +5 -4
  52. package/package.json +2 -2
  53. package/src/add-gate.mjs +25 -1
  54. package/src/add-gate.test.mjs +103 -0
  55. package/src/cli.mjs +59 -5
  56. package/src/doctor.mjs +136 -12
  57. package/src/executor.mjs +245 -0
  58. package/src/executor.test.mjs +292 -0
  59. package/src/gates.test.mjs +17 -0
  60. package/src/init.mjs +14 -1
  61. package/src/init.test.mjs +19 -0
  62. package/src/instance-ref.test.mjs +62 -0
  63. package/src/jscpd-drift.test.mjs +144 -0
  64. package/src/profiles.mjs +3 -0
  65. package/src/render.mjs +6 -1
  66. package/src/rule-budget.test.mjs +103 -0
  67. package/src/sync-hosts.mjs +41 -45
  68. package/src/sync-hosts.test.mjs +67 -56
  69. package/src/trae-hooks.test.mjs +15 -3
  70. package/src/validate.mjs +72 -0
  71. package/src/validate.test.mjs +95 -0
  72. package/templates/.gitattributes +8 -0
  73. package/templates/AGENTS.md +2 -1
  74. package/templates/_agents/commands/build.md +5 -24
  75. package/templates/_agents/commands/deploy.md +1 -0
  76. package/templates/_agents/commands/design.md +7 -6
  77. package/templates/_agents/commands/gate-checklist.md +1 -3
  78. package/templates/_agents/commands/new-task.md +42 -21
  79. package/templates/_agents/commands/plan.md +1 -1
  80. package/templates/_agents/commands/review.md +1 -1
  81. package/templates/_agents/commands/sync-hosts.md +8 -16
  82. package/templates/_agents/commands/test.md +5 -4
  83. package/templates/_agents/control-surfaces.txt +53 -0
  84. package/templates/_agents/engine-lock.json +246 -0
  85. package/templates/_agents/lane-surfaces.txt +1 -1
  86. package/templates/_agents/level-hints.txt +30 -0
  87. package/templates/_agents/scripts/agg-delegations.cjs +51 -6
  88. package/templates/_agents/scripts/agg-delegations.test.mjs +84 -4
  89. package/templates/_agents/scripts/check-engine-integrity.mjs +217 -0
  90. package/templates/_agents/scripts/check-engine-integrity.test.mjs +215 -0
  91. package/templates/_agents/scripts/check-lane-surface.mjs +78 -24
  92. package/templates/_agents/scripts/check-lane-surface.test.mjs +66 -0
  93. package/templates/_agents/scripts/check-ledger-invariant.mjs +112 -14
  94. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +5 -1
  95. package/templates/_agents/scripts/check-loop-rev.test.mjs +6 -4
  96. package/templates/_agents/scripts/check-loop.mjs +255 -45
  97. package/templates/_agents/scripts/check-loop.test.mjs +330 -15
  98. package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
  99. package/templates/_agents/scripts/compact-ledgers.mjs +97 -0
  100. package/templates/_agents/scripts/compact-ledgers.test.mjs +162 -0
  101. package/templates/_agents/scripts/confirm-doc.mjs +54 -12
  102. package/templates/_agents/scripts/confirm-doc.test.mjs +59 -0
  103. package/templates/_agents/scripts/doctor.test.mjs +294 -1
  104. package/templates/_agents/scripts/draft-sediment.mjs +178 -0
  105. package/templates/_agents/scripts/draft-sediment.test.mjs +272 -0
  106. package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
  107. package/templates/_agents/scripts/fill-intent.mjs +2 -2
  108. package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
  109. package/templates/_agents/scripts/fixture-scripts.test.mjs +107 -0
  110. package/templates/_agents/scripts/gate-checklist.mjs +6 -3
  111. package/templates/_agents/scripts/gate-checklist.test.mjs +2 -5
  112. package/templates/_agents/scripts/gen-wiki-board.mjs +61 -6
  113. package/templates/_agents/scripts/gen-wiki-board.test.mjs +74 -1
  114. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +10 -13
  115. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -30
  116. package/templates/_agents/scripts/git-merge-jsonl.mjs +63 -0
  117. package/templates/_agents/scripts/git-merge-jsonl.test.mjs +125 -0
  118. package/templates/_agents/scripts/kb-search.mjs +87 -12
  119. package/templates/_agents/scripts/kb-search.test.mjs +83 -0
  120. package/templates/_agents/scripts/ledger-doc-chain.test.mjs +166 -0
  121. package/templates/_agents/scripts/ledger-loader.mjs +108 -0
  122. package/templates/_agents/scripts/ledger-loader.test.mjs +131 -0
  123. package/templates/_agents/scripts/ledger-prune.mjs +96 -0
  124. package/templates/_agents/scripts/ledger-prune.test.mjs +120 -0
  125. package/templates/_agents/scripts/ledger-ts-monotonic.test.mjs +135 -0
  126. package/templates/_agents/scripts/level-criteria.mjs +232 -0
  127. package/templates/_agents/scripts/level-criteria.test.mjs +279 -0
  128. package/templates/_agents/scripts/pipeline-run.mjs +5 -3
  129. package/templates/_agents/scripts/policy.mjs +47 -0
  130. package/templates/_agents/scripts/solidify-task.mjs +1 -1
  131. package/templates/_agents/scripts/stage-gates.mjs +52 -10
  132. package/templates/_agents/scripts/stage-gates.test.mjs +105 -0
  133. package/templates/_agents/scripts/verify-wiki-consistency.mjs +34 -0
  134. package/templates/_agents/scripts/verify-wiki-consistency.test.mjs +44 -2
  135. package/templates/_agents/scripts/verify.mjs +1 -1
  136. package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
  137. package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
  138. package/templates/_agents/scripts/workflows-check.mjs +1 -1
  139. package/templates/_agents/scripts-test-exempt.txt +3 -1
  140. package/templates/_agents/skills/wiki/SKILL.md +5 -3
  141. package/templates/_agents/workflows/pipeline-closing.md +4 -5
  142. package/templates/_githooks/pre-commit +3 -34
  143. package/templates/_githooks/pre-push +3 -3
  144. package/templates/wiki/INDEX.md +44 -1
  145. package/templates/wiki//347/237/245/350/257/206/346/262/211/346/267/200/346/200/273/350/247/210.html +22 -1
  146. package/templates/workflow/README.md +11 -0
  147. package/templates/workflow/delegations.md +5 -4
  148. package/templates/workflow/incidents/_TEMPLATE.md +2 -0
  149. package/templates/workflow/intents/_TEMPLATE.md +3 -0
  150. package/templates/workflow/plans/_TEMPLATE.md +4 -0
  151. package/templates/workflow/specs/_TEMPLATE.md +23 -0
  152. package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -91
  153. package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -91
  154. package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -91
  155. package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -91
  156. package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -92
  157. package/templates/_agents/commands/source-sync-check.md +0 -105
  158. package/templates/_agents/scripts/check-ledger.mjs +0 -109
  159. package/templates/_agents/scripts/check-ledger.test.mjs +0 -197
  160. package/templates/_agents/scripts/source-sync-check.mjs +0 -156
  161. package/templates/_agents/scripts/source-sync-check.test.mjs +0 -281
  162. package/templates/_agents/workflows/source-sync-repair.md +0 -30
@@ -0,0 +1,292 @@
1
+ // executor.test.mjs — flow-kit route / run 薄壳套件(2026-10-13 flow-kit-route)
2
+ // 方法:子进程 driver 调真实 src/executor.mjs(route 内部会 process.exit,隔离到子进程才能测退出码与 stdout);
3
+ // 装户 fixture 只放薄壳**依赖面**文件(level-criteria.mjs 真拷、stage-gates/pipeline-run 用哨兵 stub)。
4
+ // 覆盖:route 档位与入口映射 / hints 抬档与 underHint / 泳道真取装户单源(缺失时回落并标注)/
5
+ // --kind·--level 非法值 fail-loud / 缺判据单源 fail-loud / --json 机读面 /
6
+ // run 参数·退出码透传 / run 缺子命令用法提示 / run 缺 pipeline-run 指向 init-sync。
7
+ // 用法:node src/executor.test.mjs
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ const SRC = path.dirname(fileURLToPath(import.meta.url));
15
+ const PKG = path.resolve(SRC, '..');
16
+ const REAL_CRIT = path.join(PKG, 'templates/_agents/scripts/level-criteria.mjs');
17
+ const REAL_ENUMS_MJS = path.join(PKG, 'templates/_agents/scripts/workflow-enums.mjs');
18
+ const REAL_ENUMS_TXT = path.join(PKG, 'templates/_agents/workflow-enums.txt');
19
+
20
+ let pass = 0, failCount = 0;
21
+ function check(name, cond, detail = '') {
22
+ if (cond) { pass++; console.log(` ok ${name}`); }
23
+ else { failCount++; console.error(`FAIL ${name}${detail ? ' — ' + detail : ''}`); }
24
+ }
25
+ const W = (p, content) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content, 'utf8'); };
26
+
27
+ // 装户 fixture:判据单源与枚举单源真拷;stage-gates / pipeline-run 用哨兵 stub(证明薄壳是「调用装户」而非自带实现)
28
+ // withEnums 默认 true = 完整装户面。降级通道(2026-10-13)要求能断言「完整态无降级」,
29
+ // 若夹具恒缺枚举单源,enums-fallback 会恒真、该断言即假护栏。
30
+ function mkProject({ withCrit = true, withStageGates = true, withEnums = true, withRouteTable = true, laneKey = null, hints = null, modules = null } = {}) {
31
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-exec-'));
32
+ const S = path.join(root, '.agents', 'scripts');
33
+ if (withCrit) fs.mkdirSync(S, { recursive: true }), fs.copyFileSync(REAL_CRIT, path.join(S, 'level-criteria.mjs'));
34
+ if (withStageGates) {
35
+ // 默认哨兵:按 L2/L3 → high 的常规口径(写在被写入的 stub 文件里,在装户侧求值)
36
+ const body = laneKey
37
+ ? `return ${JSON.stringify(laneKey)};`
38
+ : `return (level === 'L2' || level === 'L3') ? 'high' : 'low';`;
39
+ W(path.join(S, 'stage-gates.mjs'), `export function laneOfDoc(level){ ${body} }\n`);
40
+ }
41
+ if (hints !== null) W(path.join(root, '.agents', 'level-hints.txt'), hints);
42
+ if (modules !== null) W(path.join(root, '.agents', 'workflow-modules.txt'), modules);
43
+ if (withEnums) {
44
+ fs.mkdirSync(S, { recursive: true });
45
+ fs.copyFileSync(REAL_ENUMS_MJS, path.join(S, 'workflow-enums.mjs'));
46
+ fs.copyFileSync(REAL_ENUMS_TXT, path.join(root, '.agents', 'workflow-enums.txt'));
47
+ }
48
+ // 完整装户必带 route-kinds 机器单源(B5):kind→入口映射的权威在 new-task.md,包内不再自带一份
49
+ if (withRouteTable) {
50
+ fs.mkdirSync(path.join(root, '.agents', 'commands'), { recursive: true });
51
+ fs.writeFileSync(path.join(root, '.agents/commands/new-task.md'),
52
+ '## 3. 路由判断\n\n<!-- route-kinds: require=plan.md | fix=maintain.md | verify=test.md | review=review.md | deploy=deploy.md -->\n', 'utf8');
53
+ }
54
+ return root;
55
+ }
56
+
57
+ function runCli(args, cwd) {
58
+ // win32 绝对路径必须转 file:// URL,否则 ESM loader 判 ERR_UNSUPPORTED_ESM_URL_SCHEME(protocol 'e:')
59
+ const href = pathToFileURL(path.join(SRC, 'executor.mjs')).href;
60
+ const driver = `import(${JSON.stringify(href)})`
61
+ + `.then(m=>m.${args[0] === 'run' ? 'runPipeline' : 'route'}(${JSON.stringify(args.slice(1))}))`;
62
+ return spawnSync(process.execPath, ['-e', driver], { cwd, encoding: 'utf8', windowsHide: true });
63
+ }
64
+ const route = (args, cwd) => runCli(['route', ...args], cwd);
65
+ const run = (args, cwd) => runCli(['run', ...args], cwd);
66
+
67
+ // ============ 场景 1:无 hints(默认装户)→ L1 起步 + 明示未配置 ============
68
+ {
69
+ const r = mkProject();
70
+ const x = route(['--kind', 'require'], r);
71
+ check('S1 exit=0', x.status === 0, `status=${x.status} ${x.stderr}`);
72
+ check('S1 给出 L1', x.stdout.includes('建议级别:L1'));
73
+ check('S1 明示机器面给不出档位(不假装判得出)', x.stdout.includes('未配置') && x.stdout.includes('L1 起步'));
74
+ check('S1 带 level-hints.txt 路径指引', x.stdout.includes('.agents/level-hints.txt'));
75
+ check('S1 输出 L1 负例面', x.stdout.includes('单模块内部重构'));
76
+ check('S1 输出自查句', x.stdout.includes('能否列出'));
77
+ check('S1 kind=require → plan.md 入口', x.stdout.includes('.agents/commands/plan.md'));
78
+ check('S1 泳道=协作道', x.stdout.includes('泳道:协作道'));
79
+ }
80
+
81
+ // ============ 场景 2:kind → 入口 md 映射(五个值全覆盖) ============
82
+ {
83
+ const r = mkProject();
84
+ const map = { require: 'plan.md', fix: 'maintain.md', verify: 'test.md', review: 'review.md', deploy: 'deploy.md' };
85
+ for (const [kind, md] of Object.entries(map)) {
86
+ const x = route(['--kind', kind, '--json'], r);
87
+ const j = JSON.parse(x.stdout);
88
+ check(`S2 kind=${kind} → ${md}`, j.nextCommand === `.agents/commands/${md}`, JSON.stringify(j.nextCommand));
89
+ }
90
+ const none = JSON.parse(route(['--json'], r).stdout);
91
+ check('S2 未给 kind → 默认 require 且显式标记未定', none.kind === '(未定)' && none.nextStage === 'plan.md');
92
+ check('S2 未给 kind 的文本面提示先判 kind', route([], r).stdout.includes('先判 kind'));
93
+ }
94
+
95
+ // ============ 场景 3:hints 抬档 + underHint ============
96
+ {
97
+ const r = mkProject({ hints: 'L3 ^src/db/migrations/.*\\.sql$\nL2 ^src/contracts/\n' });
98
+ const hit = route(['--files', 'src/db/migrations/001_init.sql', '--json'], r);
99
+ const hj = JSON.parse(hit.stdout);
100
+ check('S3 hints 命中抬到 L3', hj.level === 'L3' && hj.hintLevel === 'L3');
101
+ check('S3 matched 带命中证据', hj.matched.length > 0 && hj.matched[0].includes('001_init.sql'), JSON.stringify(hj.matched));
102
+ check('S3 L2 hints 抬到 L2', JSON.parse(route(['--files', 'src/contracts/t.ts', '--json'], r).stdout).level === 'L2');
103
+ const low = JSON.parse(route(['--level', 'L1', '--files', 'src/db/migrations/002_x.sql', '--json'], r).stdout);
104
+ check('S3 建议档低于下限 → underHint=true', low.underHint === true);
105
+ check('S3 就高不就低:level 仍取下限 L3', low.level === 'L3');
106
+ check('S3 文本面输出改判警告', route(['--level', 'L1', '--files', 'src/db/migrations/002_x.sql'], r).stdout.includes('低于路径提示下限'));
107
+ const miss = JSON.parse(route(['--files', 'src/ui/b.tsx', '--json'], r).stdout);
108
+ check('S3 未命中 → L1 且 hintLevel 空', miss.level === 'L1' && miss.hintLevel === '' && miss.matched.length === 0);
109
+ check('S3 多文件逗号/中文逗号切分', JSON.parse(route(['--files', 'src/a.ts,src/contracts/t.ts', '--json'], r).stdout).level === 'L2');
110
+ // 寻址:--dir=<path> 与 --dir <path> 等价(后者被 parseArgs 消费后不能落进 files)
111
+ const eqDir = route([`--dir=${r}`, '--kind', 'require', '--files', 'src/contracts/t.ts', '--json'], os.tmpdir());
112
+ check('S3 --dir= 形式定位装户根', JSON.parse(eqDir.stdout).level === 'L2', eqDir.stderr);
113
+ check('S3 --dir 不被误当改动文件', JSON.parse(eqDir.stdout).files.length === 1, eqDir.stdout);
114
+ }
115
+
116
+ // ============ 场景 4:泳道真取装户单源 ============
117
+ {
118
+ // 哨兵:装户 laneOfDoc 对 L1 返回 low、对 L2 返回 high —— 若薄壳自带实现(按档写死),
119
+ // 换成「L2 返回 low」的哨兵即可测出:薄壳必须问装户,不能自己判。
120
+ const r = mkProject({ laneKey: null });
121
+ W(path.join(r, '.agents', 'scripts', 'stage-gates.mjs'),
122
+ 'export function laneOfDoc(level){ return level === "L2" ? "low" : "high"; }\n');
123
+ const j = JSON.parse(route(['--kind', 'require', '--level', 'L2', '--json'], r).stdout);
124
+ check('S4 泳道取装户单源返回值(不按档写死)', j.laneKey === 'low', `laneKey=${j.laneKey}`);
125
+ check('S4 laneSource 标明单源', j.laneSource === 'stage-gates.mjs', j.laneSource);
126
+ check('S4 low → 协作道', j.lane === '协作道(快车道)');
127
+ const j2 = JSON.parse(route(['--level', 'L3', '--json'], r).stdout);
128
+ check('S4 high → 防御道', j2.laneKey === 'high' && j2.lane.includes('防御道'));
129
+
130
+ // 单源返回非法值 → 不假装取到,回落并标注
131
+ const bad = mkProject();
132
+ W(path.join(bad, '.agents', 'scripts', 'stage-gates.mjs'), 'export function laneOfDoc(){ return "banana"; }\n');
133
+ const bj = JSON.parse(route(['--level', 'L2', '--json'], bad).stdout);
134
+ check('S4 单源返回非法值 → 回落', bj.laneKey === 'high' && bj.laneSource === 'fallback', JSON.stringify(bj.laneKey));
135
+
136
+ // 单源缺失 → 回落 + 降级通道可见(2026-10-13 degradation-channel:文本与 JSON 同一出口)
137
+ const miss = mkProject({ withStageGates: false });
138
+ const mx = route(['--level', 'L2'], miss);
139
+ check('S4 单源缺失 → 回落标注', mx.stdout.includes('lane-source-fallback'), mx.stdout.slice(-200));
140
+ check('S4 降级文本面统一出口', mx.stdout.includes('降级运行'));
141
+ check('S4 缺单源不阻断路由(仍给档位与入口)', mx.stdout.includes('建议级别:L2') && mx.status === 0);
142
+ const mj = JSON.parse(route(['--level', 'L2', '--json'], miss).stdout);
143
+ check('S4 缺单源时 JSON 面标 fallback', mj.laneSource === 'fallback');
144
+ check('S4 JSON 降级数组含 lane-source-fallback', mj.degraded === true && mj.degradations.some((d) => d.code === 'lane-source-fallback'), JSON.stringify(mj.degradations));
145
+ }
146
+
147
+ // ============ 场景 4b:降级通道完整性(此前四种缺失策略混用、只有 laneSource 可见)============
148
+ {
149
+ const codesOf = (proj, args) => JSON.parse(route(args.concat(['--json']), proj).stdout).degradations.map((d) => d.code);
150
+
151
+ // 判据单源齐全、无 hints → level-hints-empty 必须登记(此前对 JSON 消费方完全静默)
152
+ const noHints = mkProject();
153
+ const c1 = codesOf(noHints, ['--level', 'L1']);
154
+ check('S4b 未配 hints → 降级登记 level-hints-empty', c1.includes('level-hints-empty'), JSON.stringify(c1));
155
+ check('S4b 配了 hints 后不再报该降级', (() => {
156
+ fs.writeFileSync(path.join(noHints, '.agents', 'level-hints.txt'), '# x\nL2 ^src/db/\n');
157
+ return !codesOf(noHints, ['--level', 'L1']).includes('level-hints-empty');
158
+ })());
159
+
160
+ // 枚举单源缺失 → enums-fallback(此前静默用自带表)
161
+ const noEnums = mkProject({ withEnums: false });
162
+ check('S4b 枚举缺失 → 降级登记 enums-fallback', codesOf(noEnums, ['--level', 'L1']).includes('enums-fallback'), JSON.stringify(codesOf(noEnums, ['--level', 'L1'])));
163
+
164
+ // 建议档低于 hints 下限 → under-hint-proposed(此前仅 warning 字段,无统一出口)
165
+ const under = mkProject();
166
+ fs.writeFileSync(path.join(under, '.agents', 'level-hints.txt'), 'L3 ^src/db/\n');
167
+ const c3 = codesOf(under, ['--level', 'L1', '--files', 'src/db/schema.sql']);
168
+ check('S4b 判低 → 降级登记 under-hint-proposed 且仍按就高不就低取 L3', c3.includes('under-hint-proposed')
169
+ && JSON.parse(route(['--level', 'L1', '--files', 'src/db/schema.sql', '--json'], under).stdout).level === 'L3', JSON.stringify(c3));
170
+
171
+ // 完整态(hints + 枚举 + 单源齐全)→ degraded=false,降级数组为空
172
+ const full = mkProject();
173
+ fs.writeFileSync(path.join(full, '.agents', 'level-hints.txt'), 'L2 ^src/db/\n');
174
+ const fj = JSON.parse(route(['--level', 'L1', '--json'], full).stdout);
175
+ check('S4b 完整态无降级', fj.degraded === false && fj.degradations.length === 0, JSON.stringify(fj.degradations));
176
+ }
177
+
178
+ // ============ 场景 5:非法输入 fail-loud ============
179
+ {
180
+ const r = mkProject();
181
+ const k = route(['--kind', 'bogus'], r);
182
+ check('S5 非法 kind exit=1', k.status === 1);
183
+ check('S5 非法 kind 报文列合法值', k.stderr.includes('require|fix|verify|review|deploy'), k.stderr);
184
+ const l = route(['--level', 'L9'], r);
185
+ check('S5 非法 level exit=1 且列闭集', l.status === 1 && l.stderr.includes('L0|L1|L2|L3'), l.stderr);
186
+ const nc = mkProject({ withCrit: false });
187
+ const n = route([], nc);
188
+ check('S5 缺判据单源 exit=1', n.status === 1);
189
+ check('S5 缺判据单源指向 sync 升级(不自动改盘面)', n.stderr.includes('flow-kit sync'), n.stderr);
190
+ const badHints = route(['--files', 'src/a.ts'], mkProject({ hints: 'L9 ^src/\n' }));
191
+ check('S5 非法 hints 配置 → exit=1 且报错定位', badHints.status === 1 && badHints.stderr.includes('level-hints.txt'));
192
+ }
193
+
194
+ // ============ 场景 6:--json 机读面 ============
195
+ {
196
+ const r = mkProject({ hints: 'L2 ^src/contracts/\n', modules: '# 注释\nbilling\norder\n' });
197
+ const x = route(['--kind', 'fix', '--files', 'src/contracts/t.ts', '--json'], r);
198
+ check('S6 exit=0 且 stdout 是纯 JSON', x.status === 0 && (() => { try { JSON.parse(x.stdout); return true; } catch { return false; } })(), x.stdout.slice(0, 120));
199
+ const j = JSON.parse(x.stdout);
200
+ check('S6 字段齐备', ['level', 'lane', 'laneKey', 'laneSource', 'kind', 'files', 'proposed', 'hintLevel', 'matched', 'underHint', 'hintsConfigured', 'nextStage', 'nextCommand', 'modules']
201
+ .every((k) => k in j), Object.keys(j).join(','));
202
+ check('S6 modules 读装户词表(去注释)', Array.isArray(j.modules) && j.modules.length === 2 && !j.modules.includes('# 注释'), JSON.stringify(j.modules));
203
+ check('S6 JSON 面不含人读散文(判据面归单源)', !x.stdout.includes('能否列出'));
204
+ }
205
+
206
+ // ============ 场景 7:run 透传(参数 + 退出码 + cwd) ============
207
+ {
208
+ const r = mkProject();
209
+ W(path.join(r, '.agents', 'scripts', 'pipeline-run.mjs'),
210
+ 'const a = process.argv.slice(2);\n'
211
+ + 'console.log("STUB_PIPE " + JSON.stringify(a) + " cwd=" + process.cwd());\n'
212
+ + 'process.exit(a[0] === "boom" ? 7 : 0);\n');
213
+ const st = run(['status'], r);
214
+ check('S7 status 透传成功 exit=0', st.status === 0, st.stderr);
215
+ check('S7 参数原样送达装户状态机', st.stdout.includes('STUB_PIPE ["status"]'), st.stdout.trim());
216
+ check('S7 cwd = 装户根', st.stdout.includes(path.resolve(r).replace(/\\/g, '\\')), st.stdout.trim());
217
+ const nx = run(['next'], r);
218
+ check('S7 next 透传', nx.stdout.includes('["next"]'));
219
+ const bo = run(['boom'], r);
220
+ check('S7 装户非零退出码原样回传', bo.status === 7, `status=${bo.status}`);
221
+ const args = run(['start', '给导入功能加审计字段'], r);
222
+ check('S7 含空格的需求原话不被拆词', args.stdout.includes('["start","给导入功能加审计字段"]'), args.stdout.trim());
223
+ const withDir = run(['status', '--dir', r], os.tmpdir());
224
+ check('S7 --dir 被薄壳消费、不透传状态机', withDir.stdout.includes('["status"]'), withDir.stdout.trim());
225
+ check('S7 --dir 仍定位到装户根', withDir.stdout.includes('STUB_PIPE') && withDir.status === 0, withDir.stderr);
226
+ const dirEq = run([`--dir=${r}`, 'status'], os.tmpdir());
227
+ check('S7 --dir= 形式同样被消费', dirEq.stdout.includes('["status"]'), dirEq.stdout.trim());
228
+ }
229
+
230
+ // ============ 场景 8:run 的 fail-loud 与边界 ============
231
+ {
232
+ const r = mkProject();
233
+ const noSub = run([], r);
234
+ check('S8 缺子命令 exit=1', noSub.status === 1);
235
+ check('S8 缺子命令列子命令闭集', noSub.stderr.includes('start|next|status|watch|abort'), noSub.stderr);
236
+ check('S8 缺子命令指向宿主面驱动协议权威', noSub.stderr.includes('.agents/commands/pipeline-run.md'), noSub.stderr);
237
+ const bare = mkProject();
238
+ fs.unlinkSync(path.join(bare, '.agents', 'scripts', 'level-criteria.mjs'));
239
+ const miss = run(['status'], bare);
240
+ check('S8 缺 pipeline-run exit=1', miss.status === 1);
241
+ check('S8 缺脚本指向 init/sync(不静默装一遍)', miss.stderr.includes('flow-kit init') && miss.stderr.includes('flow-kit sync'), miss.stderr);
242
+ const notInstalled = run(['status'], mkProject({ withCrit: false }));
243
+ check('S8 非装户目录 → 明确报未安装', notInstalled.stderr.includes('不是已安装 flow-kit 的项目'), notInstalled.stderr);
244
+ }
245
+
246
+ // ============ 场景 9:薄壳纪律 —— 包面不复制判据/不自带实现 ============
247
+ {
248
+ const ex = fs.readFileSync(path.join(SRC, 'executor.mjs'), 'utf8');
249
+ check('S9 包面不硬编码判据散文', !ex.includes('聚合投影键') && !ex.includes('能否列出'));
250
+ check('S9 包面不硬编码泳道 low/high 口径', !/level === 'L2' \|\| level === 'L3'/.test(ex));
251
+ // 纪律只查**代码里的引用形式**(引号包裹的实参),不查注释散文——注释里出现「不带 --no-verify」是
252
+ // 纪律声明本身,不是违规;把它也算违规等于逼人删掉自述。
253
+ check('S9 薄壳代码不出现 --no-verify 实参', !ex.includes("'--no-verify'") && !ex.includes('"--no-verify"'));
254
+ check('S9 薄壳不 spawn AI 会话', !/claude|codex|opencode|gemini/.test(ex));
255
+ check('S9 判据单源从装户面动态载入(不在包内拷一份)', ex.includes('level-criteria.mjs') && ex.includes('import('));
256
+ }
257
+
258
+ // ============ 场景 4c:kind→入口读装户权威表(B5:包内不再自带一份映射)============
259
+ {
260
+ // ① 默认夹具带 route-kinds 行 → routeTableSource 应为 new-task.md(非兜底)
261
+ const r = mkProject();
262
+ fs.mkdirSync(path.join(r, '.agents', 'commands'), { recursive: true });
263
+ fs.writeFileSync(path.join(r, '.agents', 'commands', 'new-task.md'),
264
+ '## 3. 路由判断\n\n<!-- route-kinds: require=plan.md | fix=maintain.md | verify=test.md | review=review.md | deploy=deploy.md -->\n', 'utf8');
265
+ const j = JSON.parse(route(['--kind', 'fix', '--json'], r).stdout);
266
+ check('S4c 路由表取自装户 new-task.md(非兜底)', j.routeTableSource === 'new-task.md', j.routeTableSource);
267
+ check('S4c kind=fix → 入口取自权威表', j.nextStage === 'maintain.md', j.nextStage);
268
+
269
+ // ② 装户把入口改了个名(模拟项目自定义)→ 包必须跟随,不得仍指向旧名
270
+ fs.writeFileSync(path.join(r, '.agents', 'commands', 'new-task.md'),
271
+ '<!-- route-kinds: require=plan.md | fix=hotfix.md | verify=test.md | review=review.md | deploy=deploy.md -->\n', 'utf8');
272
+ const j2 = JSON.parse(route(['--kind', 'fix', '--json'], r).stdout);
273
+ check('S4c 装户改入口名 → 包跟随权威表(无第二份硬编码)', j2.nextStage === 'hotfix.md', j2.nextStage);
274
+
275
+ // ③ 权威表读不到 → 回退包内默认表 + 登记降级(老版本装户无该行,不静默假装)
276
+ const r3 = mkProject({ withRouteTable: false });
277
+ const j3 = JSON.parse(route(['--kind', 'require', '--json'], r3).stdout);
278
+ check('S4c 无 route-kinds 行 → 回落 + 登记 route-table-fallback',
279
+ j3.routeTableSource === 'fallback' && j3.nextStage === 'plan.md'
280
+ && j3.degradations.some((d) => d.code === 'route-table-fallback'), JSON.stringify(j3.degradations));
281
+
282
+ // ④ kind 闭集随权威表走:表里没有的 kind 判红,报文点明值域来源
283
+ const r4 = mkProject();
284
+ fs.writeFileSync(path.join(r4, '.agents', 'commands', 'new-task.md'),
285
+ '<!-- route-kinds: require=plan.md | ship=deploy.md -->\n', 'utf8');
286
+ const bad = route(['--kind', 'fix'], r4);
287
+ check('S4c 权威表无该 kind → exit 1 且值域读自权威表',
288
+ bad.status === 1 && bad.stderr.includes('require|ship') && bad.stderr.includes('new-task.md'), bad.stderr);
289
+ }
290
+
291
+ console.log(`\n合计: PASS ${pass} / FAIL ${failCount}`);
292
+ process.exit(failCount ? 1 : 0);
@@ -121,6 +121,23 @@ function run(script, dir, env = {}) {
121
121
  check('generated 暂存改区外、工作区改区内 → exit 0', ok.status === 0, ok.stderr);
122
122
  fs.rmSync(dir, { recursive: true, force: true });
123
123
  }
124
+ {
125
+ const dir = repo();
126
+ stage(dir, 'src/app.mjs', 'export const fix = 1;\n');
127
+ stage(dir, 'tests/app.test.mjs', "import { fix } from '../src/app.mjs';\n");
128
+ const bad = run('modules/gates/test-protect/check-test-protect.mjs', dir);
129
+ check('test-protect 测试与代码同框 → exit 1', bad.status === 1 && bad.stderr.includes('test-protect'), bad.stderr);
130
+ const allowed = run('modules/gates/test-protect/check-test-protect.mjs', dir, { FLOW_KIT_ALLOW_TEST_EDIT: '1' });
131
+ check('test-protect FLOW_KIT_ALLOW_TEST_EDIT=1 → exit 0', allowed.status === 0, allowed.stderr);
132
+ fs.rmSync(dir, { recursive: true, force: true });
133
+ }
134
+ {
135
+ const dir = repo();
136
+ stage(dir, 'tests/app.test.mjs', "import { fix } from '../src/app.mjs';\n");
137
+ const ok = run('modules/gates/test-protect/check-test-protect.mjs', dir);
138
+ check('test-protect 纯测试提交 → exit 0', ok.status === 0, ok.stderr);
139
+ fs.rmSync(dir, { recursive: true, force: true });
140
+ }
124
141
 
125
142
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
126
143
  process.exit(fail ? 1 : 0);
package/src/init.mjs CHANGED
@@ -6,7 +6,7 @@ import readline from 'node:readline';
6
6
  import { execSync, spawnSync } from 'node:child_process';
7
7
  import { renderTree, renderContent, sha256, scriptTrusted, listTree } from './render.mjs';
8
8
  import { HOSTS, STACKS, STACK_ALIASES, settingsJson, commitCheckConfig, pickStackVars, isOwned, srcTemplatePath, TEMPLATE_DRIFT_EXCLUDE } from './profiles.mjs';
9
- import { doctor } from './doctor.mjs';
9
+ import { doctor, GATE_TRUST_DEFAULT } from './doctor.mjs';
10
10
 
11
11
  function fail(msg) {
12
12
  console.error(`❌ ${msg}`);
@@ -263,6 +263,13 @@ export async function init(args, pkgRoot) {
263
263
  execSync('git config merge.ours.driver true', { cwd: target, stdio: 'pipe' });
264
264
  console.log(' 已启用 merge.ours driver(.gitattributes 的 merge=ours 生效)');
265
265
  } catch { /* 可选配置,失败不阻断 */ }
266
+ try {
267
+ // 台账并集合并 driver(2026-10-12 ledger-merge-driver):确认台账是尾部追加形态,多人/多 worktree
268
+ // 并行时默认合并会给文本冲突,人工「整取一侧」会静默丢确认行。配了 driver 后合并 = 并集去重。
269
+ // 本机态配置不跨机共享(与 core.hooksPath 同款性质)——漏配则退化为文本冲突,不静默丢数据。
270
+ execSync('git config merge.jsonl-union.driver "node .agents/scripts/git-merge-jsonl.mjs %O %A %B"', { cwd: target, stdio: 'pipe' });
271
+ console.log(' 已启用 merge.jsonl-union driver(台账 .agents/*.jsonl 合并走并集去重,不再文本冲突)');
272
+ } catch { /* 可选配置,失败不阻断 */ }
266
273
  } catch {
267
274
  console.log(' ⚠️ 目标不是 git 仓库——钩子未挂载;git init 后手动执行:git config core.hooksPath .githooks');
268
275
  }
@@ -327,6 +334,12 @@ export async function init(args, pkgRoot) {
327
334
  policyVersion: 2,
328
335
  // 新装默认只跑硬规则。缺省(旧台账无此字段)在 check-loop 里仍是全量,避免升级后卫生检查突然消失。
329
336
  audit: false,
337
+ // 门禁信任档(2026-10-12 gate-trust-level):默认 'project' —— gate 安装锚失配只 WARN,
338
+ // 装户对门有最终所有权(1.7.11 起现状,此处只是把隐式默认显式命名)。多仓/组织场景改 'org'
339
+ // 即让失配升 FAIL(doctor exit 1),对齐 managed policy 的组织级强制。
340
+ // ⚠️ 默认值取 gateTrustOf / GATE_TRUST_DEFAULT 而非字面量:判据单源在 doctor.mjs(§6.6b 消费),
341
+ // 两处各写一份字符串即漂移面(N3 教训)。
342
+ gateTrust: GATE_TRUST_DEFAULT,
330
343
  createdAt: new Date().toISOString(),
331
344
  options: { hosts, stack: opt.stack, boardPort: String(opt.boardPort) },
332
345
  managed,
package/src/init.test.mjs CHANGED
@@ -136,6 +136,25 @@ check('合并:原内容为空时骨架即全文', mergeAgents('', '<!-- m -->\
136
136
  fs.rmSync(tmp, { recursive: true, force: true });
137
137
  }
138
138
 
139
+ // ---- ⑨ 引擎基线不下发(装户端内容清理后:包源径基线的键命名空间与装户盘面不一致,下发即污染)----
140
+ {
141
+ const src = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-lock-src-'));
142
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-lock-tgt-'));
143
+ fs.mkdirSync(path.join(src, '_agents', 'scripts'), { recursive: true });
144
+ fs.writeFileSync(path.join(src, '_agents', 'scripts', 'gate.mjs'), '// 正常模板件\n');
145
+ fs.writeFileSync(path.join(src, '_agents', 'engine-lock.json'), '{"files":{}}\n');
146
+ const rels = listTree(src);
147
+ check('⑨listTree 排除 .agents/engine-lock.json(枚举面无基线)',
148
+ rels.includes('.agents/scripts/gate.mjs') && !rels.includes('.agents/engine-lock.json'),
149
+ JSON.stringify(rels));
150
+ const t = renderTree(src, tmp, {}, { force: true });
151
+ check('⑨renderTree 不落盘 .agents/engine-lock.json(written 无基线、目标不生成)',
152
+ !t.written.some((w) => w.rel === '.agents/engine-lock.json') && !fs.existsSync(path.join(tmp, '.agents', 'engine-lock.json')),
153
+ JSON.stringify(t.written.map((w) => w.rel)));
154
+ fs.rmSync(src, { recursive: true, force: true });
155
+ fs.rmSync(tmp, { recursive: true, force: true });
156
+ }
157
+
139
158
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
140
159
  if (fail) {
141
160
  console.log('\n用法:node src/init.test.mjs');
@@ -0,0 +1,62 @@
1
+ // instance-ref.test.mjs — 引擎/文档实例引用不变量门(2026-10-11 engine-output-provenance)。
2
+ // 公理:引擎面向用户的输出是证据性的——引用的每个出处装户必须可解析(README「不伪造」柱)。
3
+ // 此前 check-lane-surface.mjs 阻断消息与 check-loop.mjs WARN 引用包源仓专属的 specs/incidents/papercuts
4
+ // 实例文档(装户随包只有 _TEMPLATE 与骨架,路径悬空)——本门把不变量机器化:
5
+ // 包源随包下发件(引擎脚本 + 命令/角色/协议文档)不得引用「实例专属的带日期文档路径」。
6
+ // 判据形态(宁可窄不可误伤):
7
+ // /(incidents|specs|intents|plans)\/20\d{2}-/ 带日期的实例文档路径(目录前缀引用合法——
8
+ // new-task.md「立 incident(workflow/incidents/)」不受影响)
9
+ // /papercuts(\.md)?\s+20\d{2}/ papercuts 条目按日期引用(papercuts-cleanup-batch
10
+ // 事件名不带空格+日期,不命中)
11
+ // 例外:*.test.mjs 夹具里的合法日期文件名;board/(第三方 vendor)。出处一律写「包源仓 git 历史
12
+ // 与 CHANGELOG」——立法时间点保留,可解析指针删除。
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const PATTERNS = [
19
+ { re: /(incidents|specs|intents|plans)\/20\d{2}-/g, why: '实例带日期文档路径' },
20
+ { re: /papercuts(\.md)?\s+20\d{2}/g, why: 'papercuts 条目日期引用' },
21
+ ];
22
+ const EXTS = new Set(['.mjs', '.sh', '.cjs', '.md', '.txt', '.json']);
23
+ const SKIP_DIR = new Set(['board', '.git', 'node_modules', 'cache']);
24
+
25
+ function* walk(dir) {
26
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
27
+ if (e.isDirectory()) { if (!SKIP_DIR.has(e.name)) yield* walk(path.join(dir, e.name)); continue; }
28
+ if (!EXTS.has(path.extname(e.name))) continue;
29
+ if (e.name.endsWith('.test.mjs')) continue; // 夹具日期文件名合法
30
+ yield path.join(dir, e.name);
31
+ }
32
+ }
33
+
34
+ const targets = [
35
+ ...walk(path.join(ROOT, 'templates')),
36
+ ...fs.readdirSync(path.join(ROOT, 'src')).filter((f) => f.endsWith('.mjs') && !f.endsWith('.test.mjs')).map((f) => path.join(ROOT, 'src', f)),
37
+ ...fs.readdirSync(path.join(ROOT, 'bin')).filter((f) => f.endsWith('.mjs')).map((f) => path.join(ROOT, 'bin', f)),
38
+ ...fs.readdirSync(path.join(ROOT, 'scripts')).filter((f) => f.endsWith('.mjs')).map((f) => path.join(ROOT, 'scripts', f)),
39
+ ];
40
+
41
+ const hits = [];
42
+ let scanned = 0;
43
+ for (const abs of targets) {
44
+ scanned++;
45
+ const lines = fs.readFileSync(abs, 'utf8').replace(/\r\n/g, '\n').split('\n');
46
+ for (let i = 0; i < lines.length; i++) {
47
+ for (const { re, why } of PATTERNS) {
48
+ re.lastIndex = 0;
49
+ if (re.test(lines[i])) hits.push(`${path.relative(ROOT, abs)}:${i + 1}(${why})`);
50
+ }
51
+ }
52
+ }
53
+
54
+ if (hits.length) {
55
+ console.log(`FAIL 引擎/文档存在实例专属引用(装户径路径悬空)——${hits.length} 处:`);
56
+ for (const h of hits) console.log(` - ${h}`);
57
+ console.log('修法:出处改写为「留痕在包源仓 git 历史与 CHANGELOG,装户径不引实例路径」;用法示例用 <日期> 占位。');
58
+ process.exit(1);
59
+ }
60
+ console.log(`PASS 实例引用不变量:${scanned} 份随包源文件零实例专属日期引用`);
61
+ console.log(`\n合计: PASS 1 / FAIL 0`);
62
+ process.exit(0);
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // jscpd-drift.test.mjs — jscpd 漂移检查的纯函数 + 夹具回归(2026-10-12 jscpd-drift-gate)
3
+ // 不真跑 jscpd、不联网:runJscpd 走真 worktree + npx,离线不可靠且慢;本套件只钉判据纯函数与
4
+ // --no-run 端到端(基线可解析性)。真跑路径由 CI 的 workflow_dispatch 手动 job 覆盖。
5
+ // 用法:node src/jscpd-drift.test.mjs
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { pathToFileURL } from 'node:url';
11
+
12
+ const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..');
13
+ const SCRIPT = path.join(ROOT, 'scripts', 'jscpd-drift.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 mod = await import(pathToFileURL(SCRIPT).href);
23
+ const { loadBaseline, parseReport, compareDrift, pickRef, DRIFT_WARN_PCT, DRIFT_FAIL_PCT } = mod;
24
+
25
+ const tmp = (name, content) => {
26
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `jscpd-drift-test-${name}-`));
27
+ const f = path.join(dir, 'baseline.json');
28
+ fs.writeFileSync(f, content, 'utf8');
29
+ return f;
30
+ };
31
+ const cleanup = (...fs_) => fs_.forEach((f) => fs.rmSync(path.dirname(f), { recursive: true, force: true }));
32
+
33
+ // ---- 场景 1:真实基线可解析(消费端接的是本仓锚,不是夹具)----
34
+ {
35
+ const r = loadBaseline();
36
+ check('场景 1:本仓基线可解析且六键齐', r.ok, r.error);
37
+ check('场景 1:基线含 commit 锚 + 复现命令 + 日期',
38
+ r.ok && !!r.baseline.commit && !!r.baseline.command && !!r.baseline.date,
39
+ r.ok ? JSON.stringify(r.baseline) : 'load 失败');
40
+ check('场景 1:默认阈值单调(0 < warn ≤ fail)',
41
+ DRIFT_WARN_PCT > 0 && DRIFT_FAIL_PCT >= DRIFT_WARN_PCT, `${DRIFT_WARN_PCT} / ${DRIFT_FAIL_PCT}`);
42
+ check('场景 1:基线重复率在合理区间(0 < pct ≤ 100)',
43
+ r.ok && r.baseline.totals.duplicatedLinesPct > 0 && r.baseline.totals.duplicatedLinesPct <= 100,
44
+ r.ok ? String(r.baseline.totals.duplicatedLinesPct) : '');
45
+ }
46
+
47
+ // ---- 场景 2:fail-loud —— 判据的输入坏了必须可见(红线:无判定依据不产噪声,但也绝不静默)----
48
+ {
49
+ const missing = tmp('missing', '{}');
50
+ check('场景 2:totals 缺键 → fail-loud 且点名缺哪些键',
51
+ !loadBaseline(missing).ok && /缺键或非法/.test(loadBaseline(missing).error), loadBaseline(missing).error);
52
+ cleanup(missing);
53
+
54
+ const noCommit = tmp('nocommit', JSON.stringify({ totals: { sources: 1, lines: 2, clones: 1, duplicatedLines: 1, duplicatedLinesPct: 1, duplicatedTokensPct: 1 }, command: 'x' }));
55
+ check('场景 2:缺 commit 锚 → fail-loud(基线量的是哪一版无法对质)',
56
+ !loadBaseline(noCommit).ok && /commit/.test(loadBaseline(noCommit).error), loadBaseline(noCommit).error);
57
+ cleanup(noCommit);
58
+
59
+ const noCmd = tmp('nocmd', JSON.stringify({ totals: { sources: 1, lines: 2, clones: 1, duplicatedLines: 1, duplicatedLinesPct: 1, duplicatedTokensPct: 1 }, commit: 'abc1234' }));
60
+ check('场景 2:缺复现命令 → fail-loud(口径不可复现)',
61
+ !loadBaseline(noCmd).ok && /command/.test(loadBaseline(noCmd).error), loadBaseline(noCmd).error);
62
+ cleanup(noCmd);
63
+
64
+ const bad = tmp('bad', '{ not json');
65
+ check('场景 2:JSON 不可解析 → fail-loud', !loadBaseline(bad).ok && /解析失败/.test(loadBaseline(bad).error), loadBaseline(bad).error);
66
+ cleanup(bad);
67
+
68
+ const absent = path.join(os.tmpdir(), 'jscpd-drift-nonexistent-baseline.json');
69
+ check('场景 2:文件不存在 → fail-loud(不是「无数据即 OK」)', !loadBaseline(absent).ok, loadBaseline(absent).error);
70
+
71
+ const neg = tmp('neg', JSON.stringify({ totals: { sources: 1, lines: 2, clones: -1, duplicatedLines: 1, duplicatedLinesPct: 1, duplicatedTokensPct: 1 }, commit: 'a', command: 'b' }));
72
+ check('场景 2:负数值 → fail-loud', !loadBaseline(neg).ok, loadBaseline(neg).error);
73
+ cleanup(neg);
74
+ }
75
+
76
+ // ---- 场景 3:parseReport 跨版本字段探测 ----
77
+ {
78
+ check('场景 3:小写字段路径命中', parseReport({ statistics: { total: { sources: 2, lines: 10, clones: 1, duplicatedLines: 3, duplicatedLinesPct: 30, duplicatedTokensPct: 20 } } })?.duplicatedLinesPct === 30);
79
+ check('场景 3:驼峰字段路径命中(jscpd Statistics/Total/DuplicatedLinesPct)',
80
+ parseReport({ Statistics: { Total: { Sources: 2, Lines: 10, Clones: 1, DuplicatedLines: 3, DuplicatedLinesPct: 30, DuplicatedTokensPct: 20 } } })?.duplicatedLinesPct === 30);
81
+ check('场景 3:jscpd 5.4.1 实测字段路径命中(statistics.total.percentage / percentageTokens)',
82
+ parseReport({ statistics: { total: { sources: 116, lines: 26228, clones: 231, duplicatedLines: 2029, percentage: 7.736, percentageTokens: 6.398 } } })?.duplicatedLinesPct === 7.736);
83
+ check('场景 3:5.x 与早期 duplicatedLinesPct 混用时逐路径取首个有限数',
84
+ parseReport({ statistics: { total: { duplicatedLinesPct: 7.49, percentage: 7.736 } } })?.duplicatedLinesPct === 7.736);
85
+ check('场景 3:结构不可识别 → null(不猜、不用 0 冒充)',
86
+ parseReport({ foo: 1 }) === null && parseReport(null) === null && parseReport({}) === null);
87
+ const partial = parseReport({ statistics: { total: { sources: 2 } } });
88
+ check('场景 3:部分键缺失 → 其余为 null(compareDrift 据此判 unknown)',
89
+ partial.duplicatedLinesPct === null && partial.sources === 2, JSON.stringify(partial));
90
+ }
91
+
92
+ // ---- 场景 4:compareDrift 四态 ----
93
+ {
94
+ check('场景 4:持平(Δ0)→ ok', compareDrift(7.49, 7.49).level === 'ok');
95
+ check('场景 4:下降(Δ<0)→ ok 且不报抬升', compareDrift(7.49, 6.0).level === 'ok' && compareDrift(7.49, 6.0).deltaPct < 0);
96
+ const warn = compareDrift(7.49, 8.60);
97
+ check('场景 4:Δ+1.11pct → warn(越过 warn 阈值,未到 fail)',
98
+ warn.level === 'warn' && Math.abs(warn.deltaPct - 1.11) < 1e-9 && warn.lines.some((l) => /抬升(>\+1pct)/.test(l)), JSON.stringify(warn));
99
+ const high = compareDrift(7.49, 11.0);
100
+ check('场景 4:Δ+3.51pct → high 且提示立 intent 收敛(指回 incidents 克隆分叉采集点)',
101
+ high.level === 'high' && high.lines.some((l) => /立 intent 收敛/.test(l)) && high.lines.some((l) => /克隆分叉/.test(l)), JSON.stringify(high));
102
+ const unknown = compareDrift(7.49, null);
103
+ check('场景 4:实测取不到 → unknown(既不判 OK 也不判红——拿不到数据不是「没退化」的证据)',
104
+ unknown.level === 'unknown' && unknown.deltaPct === null, JSON.stringify(unknown));
105
+ const custom = compareDrift(10, 11, 0.5, 2.0);
106
+ check('场景 4:自定义阈值生效(Δ+1.0 在 0.5/2.0 档下仍为 warn)', custom.level === 'warn', JSON.stringify(custom));
107
+ }
108
+
109
+ // ---- 场景 4b:实测面选 commit(这条判据错则脚本退化为「自我复读」)----
110
+ {
111
+ check('场景 4b:默认比对 HEAD 而非基线 commit(扫基线 commit 必得 Δ=0,等于没测)',
112
+ pickRef(false, '9e0cbc5') === 'HEAD', pickRef(false, '9e0cbc5'));
113
+ check('场景 4b:--verify-baseline 才复扫基线 commit(口径自查,与漂移判定分离)',
114
+ pickRef(true, '9e0cbc5') === '9e0cbc5', pickRef(true, '9e0cbc5'));
115
+ }
116
+
117
+ // ---- 场景 5:--no-run 端到端(真实基线;不出网、零 jscpd 依赖)----
118
+ {
119
+ const r = spawnSync(process.execPath, [SCRIPT, '--no-run'], { cwd: ROOT, encoding: 'utf8' });
120
+ check('场景 5:--no-run → exit 0 且打印基线数值', r.status === 0 && /重复 7\.49%/.test(r.stdout), `exit=${r.status}\n${r.stdout}${r.stderr}`);
121
+ const j = spawnSync(process.execPath, [SCRIPT, '--no-run', '--json'], { cwd: ROOT, encoding: 'utf8' });
122
+ let parsed = null;
123
+ try { parsed = JSON.parse(j.stdout); } catch { /* 断言下方给出 */ }
124
+ check('场景 5:--no-run --json → 机器可读且 totals 齐',
125
+ j.status === 0 && parsed && parsed.ok === true && parsed.baseline && parsed.baseline.clones > 0, j.stdout);
126
+ check('场景 5:--json 输出含基线 commit 锚(可对质「量的是哪一版」)',
127
+ parsed && parsed.note && parsed.note.includes('基线 commit'), JSON.stringify(parsed));
128
+ }
129
+
130
+ // ---- 场景 6:阈值参数校验与未知参数 ----
131
+ {
132
+ const bad = spawnSync(process.execPath, [SCRIPT, '--warn-pct', '5', '--fail-pct', '1', '--no-run'], { cwd: ROOT, encoding: 'utf8' });
133
+ check('场景 6:warn > fail → exit 1', bad.status === 1, `exit=${bad.status}\n${bad.stderr}`);
134
+ const nan = spawnSync(process.execPath, [SCRIPT, '--warn-pct', 'abc', '--no-run'], { cwd: ROOT, encoding: 'utf8' });
135
+ check('场景 6:非数值阈值 → exit 1', nan.status === 1, `exit=${nan.status}\n${nan.stderr}`);
136
+ const unknownArg = spawnSync(process.execPath, [SCRIPT, '--wat'], { cwd: ROOT, encoding: 'utf8' });
137
+ check('场景 6:未知参数 → exit 1 且给用法', unknownArg.status === 1 && /用法/.test(unknownArg.stderr), `exit=${unknownArg.status}\n${unknownArg.stderr}`);
138
+ const badBase = spawnSync(process.execPath, [SCRIPT, '--baseline', path.join(os.tmpdir(), 'no-such-baseline.json'), '--no-run'], { cwd: ROOT, encoding: 'utf8' });
139
+ check('场景 6:基线坏 → exit 1 且报错(不静默跳过)', badBase.status === 1 && /基线/.test(badBase.stderr), `exit=${badBase.status}\n${badBase.stderr}`);
140
+ }
141
+
142
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
143
+ if (fail) { console.log('\n用法:node src/jscpd-drift.test.mjs'); process.exit(1); }
144
+ process.exit(0);
package/src/profiles.mjs CHANGED
@@ -113,6 +113,9 @@ export function isOwned(rel) {
113
113
  return rel === 'AGENTS.md' || rel === '.gitattributes' || rel.startsWith('workflow/') || rel.startsWith('wiki/')
114
114
  || rel.startsWith('.agents/notes/')
115
115
  || rel === '.agents/workflow-modules.txt' || rel === '.agents/rule-budgets.txt'
116
+ // level-hints.txt(2026-10-13 flow-kit-route):风险级别**路径下限**清单,内容项目私有(照抄别人的
117
+ // 清单等于没登记),故归装户自持、sync 永不覆盖——与 lane-surfaces.txt 同款性质。
118
+ || rel === '.agents/level-hints.txt'
116
119
  || rel === '.agents/metric-claims.txt' || rel === '.agents/metric-derivers.cjs'
117
120
  || rel === '.agents/metric-derivers.mjs'
118
121
  || rel === '.agents/hooks/local-pre-commit'
package/src/render.mjs CHANGED
@@ -43,6 +43,9 @@ export function renderTree(srcRoot, targetRoot, vars, { force = false, protectSe
43
43
  continue;
44
44
  }
45
45
  const rel = relOf(abs, srcRoot);
46
+ // 引擎基线不是模板:键在 templates/_agents/…,下发成 .agents/engine-lock.json 会让装户
47
+ // 拿包源路径对账。排除该落点,包源基线留在模板目录。
48
+ if (rel === '.agents/engine-lock.json') continue;
46
49
  const target = path.join(targetRoot, rel);
47
50
  fs.mkdirSync(path.dirname(target), { recursive: true });
48
51
  if (fs.existsSync(target) && !force) {
@@ -58,7 +61,7 @@ export function renderTree(srcRoot, targetRoot, vars, { force = false, protectSe
58
61
  if (raw.length < 512 * 1024 && !raw.includes(0)) {
59
62
  // 文本面 LF 归一(2026-09-27 closing-coverage):模板盘面 CRLF(autocrlf 检出 / 编辑器写入)与
60
63
  // LF 克隆渲染出同一字节、同一 sha——fresh sha、装户落盘、kit.json 台账三方跨环境稳定;
61
- // sync/doctor/source-sync-check 的读取侧归一(shaText)与本处写盘归一同口径闭合
64
+ // sync/doctor 的读取侧归一(shaText)与本处写盘归一同口径闭合
62
65
  const replaced = renderContent(raw.toString('utf8').replace(/\r\n/g, '\n'), vars);
63
66
  if (replaced.includes('{{')) {
64
67
  for (const m of replaced.matchAll(/\{\{([A-Z][A-Z0-9_]*)\}\}/g)) {
@@ -118,6 +121,8 @@ export function listTree(srcRoot) {
118
121
  walk(abs);
119
122
  continue;
120
123
  }
124
+ // 引擎基线不下发(命名空间错配会致装户全员误报——同 renderTree 排除,见 check-engine-integrity.mjs)
125
+ if (relOf(abs, srcRoot) === '.agents/engine-lock.json') continue;
121
126
  out.push(relOf(abs, srcRoot).split(path.sep).join('/'));
122
127
  }
123
128
  })(srcRoot);