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.
- package/README.md +81 -9
- package/modules/gates/README.md +1 -0
- package/modules/gates/test-protect/README.md +23 -0
- package/modules/gates/test-protect/check-test-protect.mjs +64 -0
- package/modules/gates/test-protect/local-pre-commit.line +1 -0
- package/modules/hosts/README.md +57 -0
- package/modules/hosts/claude/commands/wf-build.md +5 -24
- package/modules/hosts/claude/commands/wf-deploy.md +1 -0
- package/modules/hosts/claude/commands/wf-design.md +7 -6
- package/modules/hosts/claude/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/claude/commands/wf-new-task.md +42 -21
- package/modules/hosts/claude/commands/wf-plan.md +1 -1
- package/modules/hosts/claude/commands/wf-review.md +1 -1
- package/modules/hosts/claude/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/claude/commands/wf-test.md +5 -4
- package/modules/hosts/codex/commands/wf-build.md +5 -24
- package/modules/hosts/codex/commands/wf-deploy.md +1 -0
- package/modules/hosts/codex/commands/wf-design.md +7 -6
- package/modules/hosts/codex/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/codex/commands/wf-new-task.md +42 -21
- package/modules/hosts/codex/commands/wf-plan.md +1 -1
- package/modules/hosts/codex/commands/wf-review.md +1 -1
- package/modules/hosts/codex/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/codex/commands/wf-test.md +5 -4
- package/modules/hosts/cursor/commands/wf-build.md +5 -24
- package/modules/hosts/cursor/commands/wf-deploy.md +1 -0
- package/modules/hosts/cursor/commands/wf-design.md +7 -6
- package/modules/hosts/cursor/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/cursor/commands/wf-new-task.md +42 -21
- package/modules/hosts/cursor/commands/wf-plan.md +1 -1
- package/modules/hosts/cursor/commands/wf-review.md +1 -1
- package/modules/hosts/cursor/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/cursor/commands/wf-test.md +5 -4
- package/modules/hosts/opencode/commands/wf-build.md +5 -24
- package/modules/hosts/opencode/commands/wf-deploy.md +1 -0
- package/modules/hosts/opencode/commands/wf-design.md +7 -6
- package/modules/hosts/opencode/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/opencode/commands/wf-new-task.md +42 -21
- package/modules/hosts/opencode/commands/wf-plan.md +1 -1
- package/modules/hosts/opencode/commands/wf-review.md +1 -1
- package/modules/hosts/opencode/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/opencode/commands/wf-test.md +5 -4
- package/modules/hosts/trae/commands/wf-build.md +5 -24
- package/modules/hosts/trae/commands/wf-deploy.md +1 -0
- package/modules/hosts/trae/commands/wf-design.md +7 -6
- package/modules/hosts/trae/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/trae/commands/wf-new-task.md +42 -21
- package/modules/hosts/trae/commands/wf-plan.md +1 -1
- package/modules/hosts/trae/commands/wf-review.md +1 -1
- package/modules/hosts/trae/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/trae/commands/wf-test.md +5 -4
- package/package.json +2 -2
- package/src/add-gate.mjs +25 -1
- package/src/add-gate.test.mjs +103 -0
- package/src/cli.mjs +59 -5
- package/src/doctor.mjs +136 -12
- package/src/executor.mjs +245 -0
- package/src/executor.test.mjs +292 -0
- package/src/gates.test.mjs +17 -0
- package/src/init.mjs +14 -1
- package/src/init.test.mjs +19 -0
- package/src/instance-ref.test.mjs +62 -0
- package/src/jscpd-drift.test.mjs +144 -0
- package/src/profiles.mjs +3 -0
- package/src/render.mjs +6 -1
- package/src/rule-budget.test.mjs +103 -0
- package/src/sync-hosts.mjs +41 -45
- package/src/sync-hosts.test.mjs +67 -56
- package/src/trae-hooks.test.mjs +15 -3
- package/src/validate.mjs +72 -0
- package/src/validate.test.mjs +95 -0
- package/templates/.gitattributes +8 -0
- package/templates/AGENTS.md +2 -1
- package/templates/_agents/commands/build.md +5 -24
- package/templates/_agents/commands/deploy.md +1 -0
- package/templates/_agents/commands/design.md +7 -6
- package/templates/_agents/commands/gate-checklist.md +1 -3
- package/templates/_agents/commands/new-task.md +42 -21
- package/templates/_agents/commands/plan.md +1 -1
- package/templates/_agents/commands/review.md +1 -1
- package/templates/_agents/commands/sync-hosts.md +8 -16
- package/templates/_agents/commands/test.md +5 -4
- package/templates/_agents/control-surfaces.txt +53 -0
- package/templates/_agents/engine-lock.json +246 -0
- package/templates/_agents/lane-surfaces.txt +1 -1
- package/templates/_agents/level-hints.txt +30 -0
- package/templates/_agents/scripts/agg-delegations.cjs +51 -6
- package/templates/_agents/scripts/agg-delegations.test.mjs +84 -4
- package/templates/_agents/scripts/check-engine-integrity.mjs +217 -0
- package/templates/_agents/scripts/check-engine-integrity.test.mjs +215 -0
- package/templates/_agents/scripts/check-lane-surface.mjs +78 -24
- package/templates/_agents/scripts/check-lane-surface.test.mjs +66 -0
- package/templates/_agents/scripts/check-ledger-invariant.mjs +112 -14
- package/templates/_agents/scripts/check-ledger-invariant.test.mjs +5 -1
- package/templates/_agents/scripts/check-loop-rev.test.mjs +6 -4
- package/templates/_agents/scripts/check-loop.mjs +255 -45
- package/templates/_agents/scripts/check-loop.test.mjs +330 -15
- package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
- package/templates/_agents/scripts/compact-ledgers.mjs +97 -0
- package/templates/_agents/scripts/compact-ledgers.test.mjs +162 -0
- package/templates/_agents/scripts/confirm-doc.mjs +54 -12
- package/templates/_agents/scripts/confirm-doc.test.mjs +59 -0
- package/templates/_agents/scripts/doctor.test.mjs +294 -1
- package/templates/_agents/scripts/draft-sediment.mjs +178 -0
- package/templates/_agents/scripts/draft-sediment.test.mjs +272 -0
- package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
- package/templates/_agents/scripts/fill-intent.mjs +2 -2
- package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
- package/templates/_agents/scripts/fixture-scripts.test.mjs +107 -0
- package/templates/_agents/scripts/gate-checklist.mjs +6 -3
- package/templates/_agents/scripts/gate-checklist.test.mjs +2 -5
- package/templates/_agents/scripts/gen-wiki-board.mjs +61 -6
- package/templates/_agents/scripts/gen-wiki-board.test.mjs +74 -1
- package/templates/_agents/scripts/gen-workflow-dashboard.mjs +10 -13
- package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -30
- package/templates/_agents/scripts/git-merge-jsonl.mjs +63 -0
- package/templates/_agents/scripts/git-merge-jsonl.test.mjs +125 -0
- package/templates/_agents/scripts/kb-search.mjs +87 -12
- package/templates/_agents/scripts/kb-search.test.mjs +83 -0
- package/templates/_agents/scripts/ledger-doc-chain.test.mjs +166 -0
- package/templates/_agents/scripts/ledger-loader.mjs +108 -0
- package/templates/_agents/scripts/ledger-loader.test.mjs +131 -0
- package/templates/_agents/scripts/ledger-prune.mjs +96 -0
- package/templates/_agents/scripts/ledger-prune.test.mjs +120 -0
- package/templates/_agents/scripts/ledger-ts-monotonic.test.mjs +135 -0
- package/templates/_agents/scripts/level-criteria.mjs +232 -0
- package/templates/_agents/scripts/level-criteria.test.mjs +279 -0
- package/templates/_agents/scripts/pipeline-run.mjs +5 -3
- package/templates/_agents/scripts/policy.mjs +47 -0
- package/templates/_agents/scripts/solidify-task.mjs +1 -1
- package/templates/_agents/scripts/stage-gates.mjs +52 -10
- package/templates/_agents/scripts/stage-gates.test.mjs +105 -0
- package/templates/_agents/scripts/verify-wiki-consistency.mjs +34 -0
- package/templates/_agents/scripts/verify-wiki-consistency.test.mjs +44 -2
- package/templates/_agents/scripts/verify.mjs +1 -1
- package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
- package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
- package/templates/_agents/scripts/workflows-check.mjs +1 -1
- package/templates/_agents/scripts-test-exempt.txt +3 -1
- package/templates/_agents/skills/wiki/SKILL.md +5 -3
- package/templates/_agents/workflows/pipeline-closing.md +4 -5
- package/templates/_githooks/pre-commit +3 -34
- package/templates/_githooks/pre-push +3 -3
- package/templates/wiki/INDEX.md +44 -1
- package/templates/wiki//347/237/245/350/257/206/346/262/211/346/267/200/346/200/273/350/247/210.html +22 -1
- package/templates/workflow/README.md +11 -0
- package/templates/workflow/delegations.md +5 -4
- package/templates/workflow/incidents/_TEMPLATE.md +2 -0
- package/templates/workflow/intents/_TEMPLATE.md +3 -0
- package/templates/workflow/plans/_TEMPLATE.md +4 -0
- package/templates/workflow/specs/_TEMPLATE.md +23 -0
- package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -91
- package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -91
- package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -91
- package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -91
- package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -92
- package/templates/_agents/commands/source-sync-check.md +0 -105
- package/templates/_agents/scripts/check-ledger.mjs +0 -109
- package/templates/_agents/scripts/check-ledger.test.mjs +0 -197
- package/templates/_agents/scripts/source-sync-check.mjs +0 -156
- package/templates/_agents/scripts/source-sync-check.test.mjs +0 -281
- package/templates/_agents/workflows/source-sync-repair.md +0 -30
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// draft-sediment.mjs 测试(B2,2026-10-10)——纯函数直测 + fixture 子进程跑真 CLI
|
|
3
|
+
// 纯函数(extractSections/topicOf/renderDraft/setSedimentField)import 直测;CLI 场景
|
|
4
|
+
// 以「换 cwd」方式在临时仓跑真脚本(脚本按相对路径 workflow/ + wiki/ 读写,无需注入口)。
|
|
5
|
+
// 覆盖:抽节/主题取法/草稿渲染/沉淀字段回写的纯逻辑 + dry-run 不写盘 / 实跑落草稿并回写
|
|
6
|
+
// 并重跑 gen-wiki-board / 状态守卫拦非 done|closed / 无高价值节拒抽 / 路径越界拒收
|
|
7
|
+
// 用法:node .agents/scripts/draft-sediment.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 } from 'node:url';
|
|
13
|
+
import { extractSections, topicOf, renderDraft, setSedimentField } from './draft-sediment.mjs';
|
|
14
|
+
|
|
15
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const SED = path.join(SCRIPT_DIR, 'draft-sediment.mjs');
|
|
17
|
+
const TODAY = new Date().toISOString().slice(0, 10); // 与脚本内取法一致(草稿目录日期段)
|
|
18
|
+
const USAGE = '用法:node .agents/scripts/draft-sediment.test.mjs';
|
|
19
|
+
|
|
20
|
+
let pass = 0;
|
|
21
|
+
let fail = 0;
|
|
22
|
+
const check = (desc, cond, detail = '') => {
|
|
23
|
+
if (cond) { pass++; console.log(`PASS ${desc}`); }
|
|
24
|
+
else { fail++; console.log(`FAIL ${desc}${detail ? `\n${detail}` : ''}`); }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ---- 纯函数:extractSections ----
|
|
28
|
+
{
|
|
29
|
+
const text = [
|
|
30
|
+
'# INTENT — 权限收敛',
|
|
31
|
+
'',
|
|
32
|
+
'## 影响面',
|
|
33
|
+
'',
|
|
34
|
+
'- 模块:pipeline',
|
|
35
|
+
'- 数据库:无',
|
|
36
|
+
'',
|
|
37
|
+
'## 触达红线与边界',
|
|
38
|
+
'',
|
|
39
|
+
'> 这是引用块,应被剔除',
|
|
40
|
+
'<!-- 注释也应剔除 -->',
|
|
41
|
+
'<占位单行也应剔除>',
|
|
42
|
+
'- [x] 规则变更(证据:abc123)',
|
|
43
|
+
'',
|
|
44
|
+
'## 目标(不在抽取词表内)',
|
|
45
|
+
'',
|
|
46
|
+
'- 不应被抽出来',
|
|
47
|
+
'',
|
|
48
|
+
'## 影响面(空节)',
|
|
49
|
+
'',
|
|
50
|
+
'<待补>',
|
|
51
|
+
'',
|
|
52
|
+
].join('\n');
|
|
53
|
+
const got = extractSections(text, ['影响面', '触达红线']);
|
|
54
|
+
const titles = got.map((s) => s.title);
|
|
55
|
+
check('1a 抽取指定节(影响面)', titles.includes('影响面'), JSON.stringify(titles));
|
|
56
|
+
check('1b 节标题前缀匹配(「触达红线与边界」命中「触达红线」)', titles.includes('触达红线与边界'), JSON.stringify(titles));
|
|
57
|
+
check('1c 词表外节不抽(目标节缺席)', !titles.some((t) => t.startsWith('目标')), JSON.stringify(titles));
|
|
58
|
+
const yx = got.find((s) => s.title === '影响面');
|
|
59
|
+
check('1d 正文保留(模块行入库)', yx && yx.body.includes('- 模块:pipeline'), yx && yx.body);
|
|
60
|
+
const hx = got.find((s) => s.title === '触达红线与边界');
|
|
61
|
+
check('1e 占位行剔除(引用块/注释/单行尖括号均不进 body)',
|
|
62
|
+
hx && !hx.body.includes('引用块') && !hx.body.includes('注释也应') && !hx.body.includes('占位单行')
|
|
63
|
+
&& hx.body.includes('规则变更(证据:abc123)'), hx && hx.body);
|
|
64
|
+
check('1f 仅占位的空节被丢弃(「影响面(空节)」不入结果)',
|
|
65
|
+
!titles.includes('影响面(空节)'), JSON.stringify(titles));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- 纯函数:topicOf(标题行优先于文件名)----
|
|
69
|
+
{
|
|
70
|
+
check('2a 标题行优先(# INTENT — 权限收敛 胜过文件名尾段 perm)',
|
|
71
|
+
topicOf('workflow/intents/2026-10-10-perm.md', '# INTENT — 权限收敛\n') === '权限收敛', '');
|
|
72
|
+
check('2b 无标题行退文件名尾段(去日期前缀)',
|
|
73
|
+
topicOf('workflow/intents/2026-10-10-perm.md', '# 无标题文档\n') === 'perm', '');
|
|
74
|
+
check('2c 无标题无日期退文件基名',
|
|
75
|
+
topicOf('wiki/随便/a.md', '# 无标题文档\n') === 'a', '');
|
|
76
|
+
check('2d INCIDENT 标题行同样识别',
|
|
77
|
+
topicOf('workflow/incidents/2026-09-12-x.md', '# INCIDENT — 越权写操作\n') === '越权写操作', '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- 纯函数:renderDraft ----
|
|
81
|
+
{
|
|
82
|
+
const out = renderDraft({ topic: '权限收敛', date: '2026-10-10', source: 'workflow/intents/2026-10-10-perm.md',
|
|
83
|
+
sections: [{ title: '影响面', body: '- 模块:pipeline' }], kind: 'intent' });
|
|
84
|
+
check('3a frontmatter 含来源与草稿状态', out.startsWith('---\n') && out.includes('来源: workflow/intents/2026-10-10-perm.md')
|
|
85
|
+
&& out.includes('状态: 草稿(待人工归类)'), out.split('\n').slice(0, 5).join('\n'));
|
|
86
|
+
check('3b intent 渲染为「需求沉淀草稿」', out.includes('# 权限收敛(需求沉淀草稿)'), out.split('\n')[4]);
|
|
87
|
+
check('3c incident 渲染为「事故复盘草稿」',
|
|
88
|
+
renderDraft({ topic: 't', date: '2026-10-10', source: 's', sections: [], kind: 'incident' })
|
|
89
|
+
.includes('# t(事故复盘草稿)'), '');
|
|
90
|
+
check('3d 抽取节原样落到正文 ## 节下', out.includes('## 影响面\n\n- 模块:pipeline'), out);
|
|
91
|
+
check('3e 草稿头含中间态提示(引导人工归类)', out.includes('中间态'), out);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- 纯函数:setSedimentField ----
|
|
95
|
+
{
|
|
96
|
+
const fm = (body) => `---\n${body}\n---\n# INTENT — t\n`;
|
|
97
|
+
const a = setSedimentField(fm('状态: done\n级别: L1'), 'wiki/drafts-archive/2026-10-10-t/t.md');
|
|
98
|
+
check('4a 无沉淀字段 → 追加且 changed=true',
|
|
99
|
+
a.changed && a.text.includes('沉淀: wiki/drafts-archive/2026-10-10-t/t.md')
|
|
100
|
+
&& a.text.includes('# INTENT — t\n'), a.text);
|
|
101
|
+
const b = setSedimentField(fm('状态: done\n沉淀: 无'), 'wiki/drafts-archive/2026-10-10-t/t.md');
|
|
102
|
+
check('4b 「沉淀: 无」显式豁免位 → 覆盖(豁免是占位,可写)',
|
|
103
|
+
b.changed && b.text.includes('沉淀: wiki/drafts-archive/2026-10-10-t/t.md'), b.text);
|
|
104
|
+
const c = setSedimentField(fm('状态: done\n沉淀: wiki/规范/x.md'), 'wiki/drafts-archive/2026-10-10-t/t.md');
|
|
105
|
+
check('4c 已有人工登记 → 不覆盖 changed=false(防误抹)',
|
|
106
|
+
!c.changed && c.text.includes('沉淀: wiki/规范/x.md'), c.text);
|
|
107
|
+
const d = setSedimentField('# 无 frontmatter 的文档\n', 'wiki/drafts-archive/x.md');
|
|
108
|
+
check('4d 无 frontmatter → 不动 changed=false', !d.changed, d.text);
|
|
109
|
+
const e = setSedimentField(fm('状态: done\n沉淀: '), 'wiki/drafts-archive/y.md');
|
|
110
|
+
check('4e 空值沉淀位 → 覆盖(空同未填)',
|
|
111
|
+
e.changed && e.text.includes('沉淀: wiki/drafts-archive/y.md'), e.text);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- fixture:临时仓(wiki 根带 INDEX/看板锚点,供 gen-wiki-board 重跑;workflow 两目录)----
|
|
115
|
+
const mkfx = () => {
|
|
116
|
+
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'draft-sed-test-'));
|
|
117
|
+
fs.mkdirSync(path.join(fx, 'wiki', '主题A'), { recursive: true });
|
|
118
|
+
fs.mkdirSync(path.join(fx, 'wiki', 'drafts-archive'), { recursive: true });
|
|
119
|
+
fs.writeFileSync(path.join(fx, 'wiki', '主题A', 'a.md'), '# a\n', 'utf8');
|
|
120
|
+
fs.writeFileSync(path.join(fx, 'wiki', 'INDEX.md'), [
|
|
121
|
+
'# wiki 索引(fixture)', '',
|
|
122
|
+
'| 主题 | 文件数 | 用途 |', '|---|---|---|',
|
|
123
|
+
'| 主题A | 1 | fixture 主题 |',
|
|
124
|
+
'| drafts-archive | — | 原文档备份(按 日期-主题 分目录)', '',
|
|
125
|
+
'合计:**1 份**知识文档,**1 个主题**', '',
|
|
126
|
+
'---', '', '## 文件 → 主题 → 归属目录 映射表', '',
|
|
127
|
+
'### 主题A(1)', '',
|
|
128
|
+
'| 文件名 | 主题 | 归属目录 |', '|---|---|---|',
|
|
129
|
+
'| a.md | 主题A | wiki/主题A/ |', '', '---', '',
|
|
130
|
+
'## 命名规则', '', '- fixture', '',
|
|
131
|
+
].join('\n'), 'utf8');
|
|
132
|
+
fs.writeFileSync(path.join(fx, 'wiki', '知识沉淀总览.html'), [
|
|
133
|
+
'<!doctype html><html><body><script>', 'const DATA = {',
|
|
134
|
+
' "summary": { "total": 1, "topics": 1, "files": 1, "archive": 0 },',
|
|
135
|
+
' "topics": [ { "name": "主题A", "files": [',
|
|
136
|
+
' { "file": "a.md", "topic": "主题A", "dir": "./主题A/" } ]} ]',
|
|
137
|
+
'};', '</script></body></html>', '',
|
|
138
|
+
].join('\n'), 'utf8');
|
|
139
|
+
fs.mkdirSync(path.join(fx, 'workflow', 'intents'), { recursive: true });
|
|
140
|
+
fs.mkdirSync(path.join(fx, 'workflow', 'incidents'), { recursive: true });
|
|
141
|
+
return fx;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// 高价值正文(intent:影响面 + 触达红线均有实质内容)
|
|
145
|
+
const INTENT_DONE = [
|
|
146
|
+
'---', '状态: done', '级别: L1', '日期: 2026-10-10', '模块: pipeline', '---',
|
|
147
|
+
'# INTENT — 权限收敛', '',
|
|
148
|
+
'## 影响面', '', '- 模块:pipeline', '- 数据库:无', '',
|
|
149
|
+
'## 触达红线', '', '- [x] 规则 / 契约变更(证据:abc123)', '',
|
|
150
|
+
'## 验收标准(可测试)', '', '- [x] 全绿(证据:npm test)', '',
|
|
151
|
+
].join('\n');
|
|
152
|
+
|
|
153
|
+
// 高价值正文(incident:复盘三件套齐全)
|
|
154
|
+
const INC_CLOSED = [
|
|
155
|
+
'---', '状态: closed', '级别: L1', '日期: 2026-09-12', '---',
|
|
156
|
+
'# INCIDENT — 越权写操作', '',
|
|
157
|
+
'## 影响面', '', '- 只读接口被放行写操作。', '',
|
|
158
|
+
'## 根因', '', '- 鉴权中间件早于路由注册生效。', '',
|
|
159
|
+
'## 为什么之前没拦住', '', '- 测试只覆盖了 happy path。', '',
|
|
160
|
+
'## 复盘三件套', '', '- 修复:注册顺序对调(SHA abc123)。', '- 防复发:加契约测试。', '- 台账:已登记。', '',
|
|
161
|
+
].join('\n');
|
|
162
|
+
|
|
163
|
+
const runSed = (fx, targetArgs) => spawnSync(process.execPath, [SED, ...targetArgs], {
|
|
164
|
+
cwd: fx, encoding: 'utf8',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---- CLI 5:--dry-run 不写盘 ----
|
|
168
|
+
{
|
|
169
|
+
const fx = mkfx();
|
|
170
|
+
fs.writeFileSync(path.join(fx, 'workflow', 'intents', '2026-10-10-perm.md'), INTENT_DONE, 'utf8');
|
|
171
|
+
const r = runSed(fx, ['workflow/intents/2026-10-10-perm.md', '--dry-run']);
|
|
172
|
+
const out = (r.stdout || '') + (r.stderr || '');
|
|
173
|
+
check('5a --dry-run exit 0 且明示未写盘', r.status === 0 && out.includes('未写盘'), `exit=${r.status}\n${out.slice(0, 400)}`);
|
|
174
|
+
check('5b 未落草稿(drafts-archive 仍空)',
|
|
175
|
+
fs.readdirSync(path.join(fx, 'wiki', 'drafts-archive')).length === 0, '');
|
|
176
|
+
check('5c 源文件未回写(无沉淀字段)',
|
|
177
|
+
!fs.readFileSync(path.join(fx, 'workflow', 'intents', '2026-10-10-perm.md'), 'utf8').includes('沉淀:'), '');
|
|
178
|
+
check('5d 预览含来源与抽取节标题', out.includes('来源: workflow/intents/2026-10-10-perm.md')
|
|
179
|
+
&& out.includes('## 影响面') && out.includes('## 触达红线'), out);
|
|
180
|
+
check('5e 主题取标题行(权限收敛)而非文件名尾段(perm)',
|
|
181
|
+
out.includes('wiki/drafts-archive/' + TODAY + '-权限收敛'), out);
|
|
182
|
+
fs.rmSync(fx, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- CLI 6:实跑 intent —— 落草稿 + 回写沉淀 + 重跑 gen-wiki-board ----
|
|
186
|
+
{
|
|
187
|
+
const fx = mkfx();
|
|
188
|
+
fs.writeFileSync(path.join(fx, 'workflow', 'intents', '2026-10-10-perm.md'), INTENT_DONE, 'utf8');
|
|
189
|
+
const r = runSed(fx, ['workflow/intents/2026-10-10-perm.md']);
|
|
190
|
+
const out = (r.stdout || '') + (r.stderr || '');
|
|
191
|
+
const draftP = path.join(fx, 'wiki', 'drafts-archive', `${TODAY}-权限收敛`, '权限收敛.md');
|
|
192
|
+
check('6a 实跑 exit 0', r.status === 0, `exit=${r.status}\n${out.slice(0, 500)}`);
|
|
193
|
+
check('6b 草稿落到 drafts-archive/<日期-主题>/<主题>.md',
|
|
194
|
+
fs.existsSync(draftP), `期望存在:${draftP}`);
|
|
195
|
+
const draft = fs.existsSync(draftP) ? fs.readFileSync(draftP, 'utf8') : '';
|
|
196
|
+
check('6c 草稿带来源 frontmatter 指回源文件',
|
|
197
|
+
draft.startsWith('---\n') && draft.includes('来源: workflow/intents/2026-10-10-perm.md')
|
|
198
|
+
&& draft.includes('(需求沉淀草稿)'), draft.split('\n').slice(0, 6).join('\n'));
|
|
199
|
+
const src = fs.readFileSync(path.join(fx, 'workflow', 'intents', '2026-10-10-perm.md'), 'utf8');
|
|
200
|
+
check('6d 源文件回写沉淀字段(B1 闭环——断链 WARN 由此消除)',
|
|
201
|
+
src.includes(`沉淀: wiki/drafts-archive/${TODAY}-权限收敛/权限收敛.md`), src.split('\n').slice(0, 7).join('\n'));
|
|
202
|
+
const board = fs.readFileSync(path.join(fx, 'wiki', '知识沉淀总览.html'), 'utf8');
|
|
203
|
+
check('6e gen-wiki-board 已重跑(看板归档计数 0→1)',
|
|
204
|
+
/"archive": 1/.test(board) && out.includes('gen-wiki-board'), board.split('\n').slice(0, 6).join('\n'));
|
|
205
|
+
check('6f 沉淀回写幂等:再跑一次不覆盖已有登记(changed=false 分支)',
|
|
206
|
+
runSed(fx, ['workflow/intents/2026-10-10-perm.md']).status === 0
|
|
207
|
+
&& src.includes(`沉淀: wiki/drafts-archive/${TODAY}-权限收敛/权限收敛.md`), '');
|
|
208
|
+
fs.rmSync(fx, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- CLI 7:实跑 incident —— 事故口径四节 ----
|
|
212
|
+
{
|
|
213
|
+
const fx = mkfx();
|
|
214
|
+
fs.writeFileSync(path.join(fx, 'workflow', 'incidents', '2026-09-12-x.md'), INC_CLOSED, 'utf8');
|
|
215
|
+
const r = runSed(fx, ['workflow/incidents/2026-09-12-x.md']);
|
|
216
|
+
const draftP = path.join(fx, 'wiki', 'drafts-archive', `${TODAY}-越权写操作`, '越权写操作.md');
|
|
217
|
+
const draft = fs.existsSync(draftP) ? fs.readFileSync(draftP, 'utf8') : '';
|
|
218
|
+
check('7a incident 实跑 exit 0 且草稿落盘', r.status === 0 && fs.existsSync(draftP),
|
|
219
|
+
`exit=${r.status}\n${(r.stdout || '') + (r.stderr || '')}`.slice(0, 400));
|
|
220
|
+
check('7b 草稿为事故复盘口径(标题 + 四节齐备)',
|
|
221
|
+
draft.includes('(事故复盘草稿)') && ['影响面', '根因', '为什么之前没拦住', '复盘三件套']
|
|
222
|
+
.every((h) => draft.includes('## ' + h)), draft.slice(0, 300));
|
|
223
|
+
check('7c 源文件回写沉淀字段',
|
|
224
|
+
fs.readFileSync(path.join(fx, 'workflow', 'incidents', '2026-09-12-x.md'), 'utf8')
|
|
225
|
+
.includes(`沉淀: wiki/drafts-archive/${TODAY}-越权写操作/越权写操作.md`), '');
|
|
226
|
+
fs.rmSync(fx, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- CLI 8:守卫(状态 / 无高价值节 / 路径越界)----
|
|
230
|
+
{
|
|
231
|
+
const fx = mkfx();
|
|
232
|
+
// 8a 非 done intent
|
|
233
|
+
fs.writeFileSync(path.join(fx, 'workflow', 'intents', 'a-draft.md'),
|
|
234
|
+
INTENT_DONE.replace('状态: done', '状态: draft'), 'utf8');
|
|
235
|
+
const r8a = runSed(fx, ['workflow/intents/a-draft.md']);
|
|
236
|
+
check('8a 非 done intent → exit 1 且明示状态要求',
|
|
237
|
+
r8a.status === 1 && (r8a.stderr || '').includes('须为 done'), `exit=${r8a.status}\n${r8a.stderr}`);
|
|
238
|
+
// 8b 非 closed incident
|
|
239
|
+
fs.writeFileSync(path.join(fx, 'workflow', 'incidents', 'b-open.md'),
|
|
240
|
+
INC_CLOSED.replace('状态: closed', '状态: open'), 'utf8');
|
|
241
|
+
const r8b = runSed(fx, ['workflow/incidents/b-open.md']);
|
|
242
|
+
check('8b 非 closed incident → exit 1 且明示状态要求',
|
|
243
|
+
r8b.status === 1 && (r8b.stderr || '').includes('须为 closed'), `exit=${r8b.status}\n${r8b.stderr}`);
|
|
244
|
+
// 8c 高价值节全为占位 → 拒抽
|
|
245
|
+
fs.writeFileSync(path.join(fx, 'workflow', 'intents', 'c-empty.md'), [
|
|
246
|
+
'---', '状态: done', '---', '# INTENT — 空单', '',
|
|
247
|
+
'## 影响面', '', '<待补>', '', '## 触达红线', '', '<待补>', '',
|
|
248
|
+
].join('\n'), 'utf8');
|
|
249
|
+
const r8c = runSed(fx, ['workflow/intents/c-empty.md']);
|
|
250
|
+
check('8c 高价值节仅占位 → exit 1 且明示无沉淀价值',
|
|
251
|
+
r8c.status === 1 && (r8c.stderr || '').includes('未抽取到高价值节'), `exit=${r8c.status}\n${r8c.stderr}`);
|
|
252
|
+
check('8c 占位拒抽不落草稿', !fs.existsSync(path.join(fx, 'wiki', 'drafts-archive', `${TODAY}-空单`)), '');
|
|
253
|
+
// 8d 路径在 workflow/ 外
|
|
254
|
+
fs.writeFileSync(path.join(fx, 'outside.md'), INTENT_DONE, 'utf8');
|
|
255
|
+
const r8d = runSed(fx, ['outside.md']);
|
|
256
|
+
check('8d 源文件不在 workflow/intents|incidents 下 → exit 1',
|
|
257
|
+
r8d.status === 1 && (r8d.stderr || '').includes('workflow/intents'), `exit=${r8d.status}\n${r8d.stderr}`);
|
|
258
|
+
// 8e 不存在的文件
|
|
259
|
+
const r8e = runSed(fx, ['workflow/intents/不存在.md']);
|
|
260
|
+
check('8e 源文件不存在 → exit 1', r8e.status === 1, `exit=${r8e.status}\n${r8e.stderr}`);
|
|
261
|
+
// 8f 无参数
|
|
262
|
+
const r8f = runSed(fx, []);
|
|
263
|
+
check('8f 无源文件参数 → exit 1 且打印用法', r8f.status === 1, `exit=${r8f.status}`);
|
|
264
|
+
fs.rmSync(fx, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
|
|
268
|
+
if (fail) {
|
|
269
|
+
console.log(`\n${USAGE}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
process.exit(0);
|
|
@@ -36,16 +36,17 @@ check('globToRe:目录前缀', globToRe('.agents/cache/').test('.agents/cache/
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// ---- ② 端到端 fixture ----
|
|
39
|
-
|
|
39
|
+
// 夹具闭包(2026-10-12 fixture-closure-rootfix):此前手写 SCRIPTS 枚举,漏拷 ledger-loader / ledger-prune
|
|
40
|
+
// 就让 check-loop import 失败 → 门禁静默不跑 → **假绿**。改为按入口自动求传递闭包,新增依赖自动跟随。
|
|
41
|
+
const { copyScriptClosure, copyAgentsFiles } = await import('./fixture-scripts.mjs');
|
|
40
42
|
// 引用完整性哑文件(检查 4/6/7 的存在性判据——避免 fixture 结构性 WARN 淹没断言面)
|
|
41
43
|
const REF_COMMANDS = ['new-task', 'plan', 'design', 'build', 'test', 'deploy', 'maintain', 'review'];
|
|
42
44
|
const REF_ROLES = ['implementer', 'independent-reviewer', 'ui-verifier'];
|
|
43
45
|
function mkFixture({ breakStageIndex, overBudget }) {
|
|
44
46
|
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'efc-'));
|
|
45
47
|
fs.mkdirSync(path.join(fx, '.agents', 'scripts'), { recursive: true });
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (fs.existsSync(path.join(SRC, '..', f))) fs.copyFileSync(path.join(SRC, '..', f), path.join(fx, '.agents', f));
|
|
48
|
+
copyScriptClosure(path.join(fx, '.agents', 'scripts'), ['edit-face-check.mjs', 'check-loop.mjs']);
|
|
49
|
+
copyAgentsFiles(path.join(fx, '.agents'), ['kit.json', 'workflow-modules.txt', 'workflow-enums.txt']);
|
|
49
50
|
fs.mkdirSync(path.join(fx, 'workflow', 'intents'), { recursive: true });
|
|
50
51
|
for (const d of ['specs', 'plans', 'incidents']) fs.mkdirSync(path.join(fx, 'workflow', d), { recursive: true });
|
|
51
52
|
for (const c of REF_COMMANDS) { fs.mkdirSync(path.join(fx, '.agents', 'commands'), { recursive: true }); fs.writeFileSync(path.join(fx, '.agents', 'commands', `${c}.md`), `# ${c}\n`); }
|
|
@@ -33,8 +33,8 @@ const SECTIONS = [
|
|
|
33
33
|
{ title: '目标', hint: '- <可验证的目标>' },
|
|
34
34
|
{ title: '非目标(L1 微改动无实质内容可删本节,不硬填)', hint: '- <明确不做的,防止范围蔓延>' },
|
|
35
35
|
{ title: '约束(L1 微改动无实质内容可删本节,不硬填)', hint: '- <复用什么、不许动什么>' },
|
|
36
|
-
{ title: '影响面', hint: '- 模块:<词表之一,见 .agents/workflow-modules.txt>\n- 数据库:无 / <表名>(涉及 schema 变更 → L3;无 schema 的运行时 / 管线结构面亦为 L3
|
|
37
|
-
{ title: '触达红线(对照 AGENTS.md,勾选仅标记触及范围;具体如何满足在同名 spec 或 Quick-Plan 中说明)', hint: '- [ ] <按本项目 AGENTS.md 红线逐行补;无则删本行>\n- [ ]
|
|
36
|
+
{ title: '影响面', hint: '- 模块:<词表之一,见 .agents/workflow-modules.txt>\n- 数据库:无 / <表名>(涉及 schema 变更 → L3;无 schema 的运行时 / 管线结构面亦为 L3,判据单源见 .agents/scripts/level-criteria.mjs 的 L3_SURFACES)\n- 前端页面:<路由或组件,无则删本行>' },
|
|
37
|
+
{ title: '触达红线(对照 AGENTS.md,勾选仅标记触及范围;具体如何满足在同名 spec 或 Quick-Plan 中说明)', hint: '- [ ] <按本项目 AGENTS.md 红线逐行补;无则删本行>\n- [ ] 触及判据单源 `.agents/scripts/level-criteria.mjs` 的 `L2_SURFACES` 触达面闭集 → 级别至少 L2\n- [ ] 触及同源的 `L3_SURFACES` 结构面闭集 → 级别 L3\n\n> 两条闭集只在该单源一处定义,**逐条对照单源勾选,不要凭记忆复述**(此处曾各写一份且条目不一致,判低即错判)。\n> 触及任一红线即为 L2 / L3,必须立 ../specs/YYYY-MM-DD-<主题>.md 写明如何满足,方可起草 plan。' },
|
|
38
38
|
{ title: '验收标准(可测试)', hint: '- [ ] <逐条可测试;写不出可测试判据 = 还没想清楚>\n\n> **闭环对账**:关单在 test 阶段(不依赖 deploy)。intent 置 done 前逐条勾验,每条补证据——`- [x] <判据>(证据:<commit SHA / 测试用例名 / 冒烟脚本输出>)`。\n> done 状态仍有未勾项会被 check-loop 拦截(2026-09-12 起新建 intent 为 hard-block,存量 intent 仅 warning 提示);勾选但缺「证据:」为 hard-block。' },
|
|
39
39
|
];
|
|
40
40
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// fixture-scripts.mjs — 测试夹具脚本闭包工具(2026-10-12 fixture-closure-rootfix)
|
|
2
|
+
//
|
|
3
|
+
// 为什么有它:夹具此前用**手工枚举**把被测脚本及其依赖拷进临时根(如 `const SCRIPTS = ['check-loop.mjs',
|
|
4
|
+
// 'policy.mjs', ...]`)。那份枚举必须与被测脚本真实的依赖闭包一致,但两者互不校验——给脚本加一行
|
|
5
|
+
// `import './x.mjs'`,枚举不会自动跟着变,而滞后的表现是**静默绿**:ESM resolve 失败 → 被测脚本根本没跑
|
|
6
|
+
// → 断言面为空 → 套件判绿。2026-10-12 那批台账改动就因此在 `check-loop.test` / `edit-face-check.test` /
|
|
7
|
+
// `ledger-prune.test` 三处先后假绿(各自补拷才转绿)。补齐是止血,消灭枚举才是根治。
|
|
8
|
+
//
|
|
9
|
+
// 本模块给两样东西,各自独立成立:
|
|
10
|
+
// ① scriptClosure / copyScriptClosure —— 自动求依赖传递闭包并整闭包拷贝,维护面归零(新增依赖自动跟随);
|
|
11
|
+
// ② assertFixtureScripts —— 拷完在**目标目录**独立复扫一遍,任一相对依赖解析不到就抛错报全量缺失清单。
|
|
12
|
+
// 这是纵深防御:即使将来有人又手写枚举(或绕过本 helper 直接拷),漏拷会在建夹具那一刻响亮炸掉,
|
|
13
|
+
// 而不是几十个场景之后以「门禁没跑」的形式悄悄绿掉。
|
|
14
|
+
//
|
|
15
|
+
// 依赖发现口径(刻意取宽):**静态 import/export from**、**动态 import()**、以及**裸文件名字面量**
|
|
16
|
+
// (形如 path.join(here, 'kb-search.mjs') 的 spawn 依赖——wiki-search.mjs 就只靠这一条挂住 kb-search;
|
|
17
|
+
// check-hygiene 靠它挂住 rule-budget.sh 与两个生成器)。裸文件名只在**解析到同目录真实文件**时才计入,
|
|
18
|
+
// 故 `'../hooks/commit-check.cjs'` 这类跨目录字面量天然被排除(scripts/ 下不存在同名文件)。
|
|
19
|
+
// 宁可多拷(多一个文件不会让测试变假绿),不可漏拷(漏拷就是假绿)。
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
export const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
|
|
26
|
+
const SCRIPT_EXT = /\.(?:mjs|cjs|js|sh)$/;
|
|
27
|
+
// 裸文件名 / ./x / ../x 三种形态;含目录分隔的更长路径字面量(如 'templates/_agents/scripts/gate.mjs')不匹配。
|
|
28
|
+
// 点开头的隐藏名不匹配:check-loop.mjs 里有 `.replace(/\.mjs$/, '.test.mjs')`,那个 '.test.mjs'
|
|
29
|
+
// 是**后缀字面量**不是文件名,按名解析必然扑空(对每个测试夹具都误报「漏拷」→ 整套件红)。
|
|
30
|
+
const BARE_NAME = /^(?:\.{1,2}\/)?[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
31
|
+
// 引号字面量(单/双引号皆可),逐行扫即可——模板字面量里的文件名由动态拼接给出,不在此列
|
|
32
|
+
const QUOTED = /'([^'\n]*)'|"([^"\n]*)"/g;
|
|
33
|
+
|
|
34
|
+
// stripComments:抹掉注释,保留字符串字面量内容。
|
|
35
|
+
// 刻意**不用正则**:正则版把 `confirmations.d/*.jsonl` 这类注释里的 `/*` 当块注释起点,跨注释一路吃到下一个
|
|
36
|
+
// 真 `*/`,把后面的 import 整段吃掉(check-ledger-invariant 实测:12141 字符被砍到 4198,43 行的
|
|
37
|
+
// import 直接消失 → 闭包漏依赖 → 回到假绿)。故走一个 40 行的逐字符状态机,只认三种真形态。
|
|
38
|
+
// 字符串内容**不抹**(我们要的就是字面量里的文件名),故正则里的 ".*?/*.jsonl" 这类串不被误当注释。
|
|
39
|
+
function stripComments(src) {
|
|
40
|
+
let out = '';
|
|
41
|
+
let i = 0;
|
|
42
|
+
const n = src.length;
|
|
43
|
+
while (i < n) {
|
|
44
|
+
const c = src[i];
|
|
45
|
+
const d = src[i + 1];
|
|
46
|
+
if (c === '/' && d === '/') { while (i < n && src[i] !== '\n') i++; continue; }
|
|
47
|
+
if (c === '/' && d === '*') {
|
|
48
|
+
i += 2;
|
|
49
|
+
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
|
|
50
|
+
i += 2;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
54
|
+
const quote = c;
|
|
55
|
+
out += c;
|
|
56
|
+
i++;
|
|
57
|
+
while (i < n) {
|
|
58
|
+
if (src[i] === '\\') { out += src[i] + (src[i + 1] || ''); i += 2; continue; }
|
|
59
|
+
out += src[i];
|
|
60
|
+
if (src[i] === quote) { i++; break; }
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
out += c;
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// depsOf:某脚本源码里引用的、可能落在同目录的脚本文件名(去重、保持出现序)。
|
|
72
|
+
// 纯文本解析——不查盘面,故「源码引用了但盘面没有的名字」也会被列出,由调用方按盘面存在性收窄。
|
|
73
|
+
export function depsOf(sourceText) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const push = (v) => { if (v && !out.includes(v)) out.push(v); };
|
|
76
|
+
const text = stripComments(sourceText);
|
|
77
|
+
// import/export ... from '...' 与 import('...')
|
|
78
|
+
for (const m of text.matchAll(/(?:\bfrom\s*|\bimport\s*\(\s*)(['"])([^'"]+)\1/g)) push(m[2]);
|
|
79
|
+
// 其余裸文件名字面量(spawn / path.join 依赖)
|
|
80
|
+
for (const m of text.matchAll(QUOTED)) push(m[1] || m[2]);
|
|
81
|
+
return out.filter((v) => SCRIPT_EXT.test(v) && BARE_NAME.test(v));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// scriptClosure:从入口出发递归求同目录依赖传递闭包(BFS,稳定序;不存在的引用不炸——由 assert 层兜)
|
|
85
|
+
export function scriptClosure(entries, { from = SCRIPT_DIR } = {}) {
|
|
86
|
+
const queue = [...new Set(entries)].map((e) => path.basename(e));
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const order = [];
|
|
89
|
+
while (queue.length) {
|
|
90
|
+
const name = queue.shift();
|
|
91
|
+
if (seen.has(name)) continue;
|
|
92
|
+
seen.add(name);
|
|
93
|
+
order.push(name);
|
|
94
|
+
const abs = path.join(from, name);
|
|
95
|
+
if (!fs.existsSync(abs)) continue;
|
|
96
|
+
for (const dep of depsOf(fs.readFileSync(abs, 'utf8'))) {
|
|
97
|
+
const base = path.basename(dep);
|
|
98
|
+
if (!seen.has(base)) queue.push(base);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return order;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// copyScriptClosure:把入口的整个依赖闭包拷进 destScriptsDir,返回实际拷入的文件名。
|
|
105
|
+
// opts.extra —— 额外要拷的同级非脚本件(如 rule-budget.sh 之外的 .txt 词表由 copyAgentsFiles 负责)。
|
|
106
|
+
export function copyScriptClosure(destScriptsDir, entries, opts = {}) {
|
|
107
|
+
const from = opts.from || SCRIPT_DIR;
|
|
108
|
+
fs.mkdirSync(destScriptsDir, { recursive: true });
|
|
109
|
+
const closure = scriptClosure(entries, { from });
|
|
110
|
+
const copied = [];
|
|
111
|
+
for (const name of closure) {
|
|
112
|
+
const src = path.join(from, name);
|
|
113
|
+
if (!fs.existsSync(src) || !fs.statSync(src).isFile()) continue;
|
|
114
|
+
const dest = path.join(destScriptsDir, name);
|
|
115
|
+
fs.copyFileSync(src, dest);
|
|
116
|
+
copied.push(name);
|
|
117
|
+
}
|
|
118
|
+
// 纵深防御:在**目标目录**独立复扫,不复用上面的闭包结果(这样才抓得到拷贝环节本身的错漏)
|
|
119
|
+
assertFixtureScripts(destScriptsDir);
|
|
120
|
+
return copied;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// copyAgentsFiles:把包源 `.agents/` 面的词表/配置(workflow-enums.txt 等)拷进夹具的 .agents/ 根
|
|
124
|
+
export function copyAgentsFiles(destAgentsDir, names, { from = path.join(SCRIPT_DIR, '..') } = {}) {
|
|
125
|
+
fs.mkdirSync(destAgentsDir, { recursive: true });
|
|
126
|
+
const copied = [];
|
|
127
|
+
for (const name of names) {
|
|
128
|
+
const src = path.join(from, name);
|
|
129
|
+
if (!fs.existsSync(src)) continue;
|
|
130
|
+
fs.copyFileSync(src, path.join(destAgentsDir, name));
|
|
131
|
+
copied.push(name);
|
|
132
|
+
}
|
|
133
|
+
return copied;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// assertFixtureScripts:扫目录内全部脚本,任一相对/裸文件名依赖解析不到 → 抛错(报全量缺失,不报第一个)
|
|
137
|
+
// 用法:夹具建好后立即调;漏拷在当场炸,而不是在「门禁静默不跑」之后绿掉。
|
|
138
|
+
export function assertFixtureScripts(dir) {
|
|
139
|
+
const missing = [];
|
|
140
|
+
if (!fs.existsSync(dir)) throw new Error(`assertFixtureScripts: 目录不存在 ${dir}`);
|
|
141
|
+
for (const f of fs.readdirSync(dir).filter((f) => SCRIPT_EXT.test(f)).sort()) {
|
|
142
|
+
const src = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
143
|
+
for (const dep of depsOf(src)) {
|
|
144
|
+
if (dep.includes('/') && dep.startsWith('..')) continue; // 跨目录引用不在本夹具职责内
|
|
145
|
+
if (!fs.existsSync(path.join(dir, dep))) missing.push(`${f} → ${dep}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (missing.length) {
|
|
149
|
+
throw new Error(`夹具依赖缺失(漏拷 → 被测脚本 import 失败 → 门禁静默不跑 = 假绿):\n ${missing.join('\n ')}\n`
|
|
150
|
+
+ '修法:改用 copyScriptClosure(入口脚本) 让闭包自动跟随,不要手写枚举。');
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// fixture-scripts.test.mjs — 夹具闭包工具自身套件(2026-10-12 fixture-closure-rootfix)
|
|
3
|
+
// 命题:本工具**只能比手工枚举多拷,绝不能少拷**——多拷的文件不会让测试假绿,少拷的文件会让
|
|
4
|
+
// 被测脚本 import 失败、门禁静默不跑、套件假绿(2026-10-12 台账那批三处假绿的根因)。
|
|
5
|
+
// 覆盖面:多层传递依赖 / node: 内建跳过 / 注释与字符串字面量不误收 / 后缀字面量('.test.mjs')不误报 /
|
|
6
|
+
// 缺失即炸且报全量清单 / spawn 依赖(裸文件名字面量)也被收进闭包。
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { SCRIPT_DIR, depsOf, scriptClosure, copyScriptClosure, assertFixtureScripts, copyAgentsFiles } from './fixture-scripts.mjs';
|
|
11
|
+
|
|
12
|
+
let pass = 0, fail = 0;
|
|
13
|
+
const check = (name, ok, detail = '') => {
|
|
14
|
+
if (ok) { pass++; console.log(` ok ${name}`); }
|
|
15
|
+
else { fail++; console.error(`FAIL ${name}${detail ? ' — ' + detail : ''}`); }
|
|
16
|
+
};
|
|
17
|
+
const tmpRoot = (p) => fs.mkdtempSync(path.join(os.tmpdir(), `fxsc-${p}-`));
|
|
18
|
+
// 造一个合成 scripts 目录(不触真实盘面——本工具的依赖发现是纯文本解析,故合成目录可独立验证)
|
|
19
|
+
const synth = (files) => {
|
|
20
|
+
const dir = tmpRoot('synth');
|
|
21
|
+
for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), body, 'utf8');
|
|
22
|
+
return dir;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ---- S1:多层传递依赖闭包 ----
|
|
26
|
+
{
|
|
27
|
+
const src = synth({
|
|
28
|
+
'a.mjs': `import './b.mjs';\nimport { x } from './sub/c.mjs';\n`,
|
|
29
|
+
'b.mjs': `import './c.mjs';\n`,
|
|
30
|
+
'c.mjs': `export const x = 1;\n`,
|
|
31
|
+
'd.mjs': `export const y = 2;\n`,
|
|
32
|
+
});
|
|
33
|
+
const cl = scriptClosure(['a.mjs'], { from: src });
|
|
34
|
+
check('S1 三层传递依赖全部收进闭包', ['a.mjs', 'b.mjs', 'c.mjs'].every((f) => cl.includes(f)) && !cl.includes('d.mjs'), cl.join(','));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- S2:node: 内建与跨目录字面量不误收 ----
|
|
38
|
+
{
|
|
39
|
+
const src = synth({
|
|
40
|
+
'a.mjs': `import fs from 'node:fs';\nimport { x } from './b.mjs';\nconst p = path.join(h, 'templates/_agents/scripts/gate.mjs');\n`,
|
|
41
|
+
'b.mjs': 'export const x=1;\n',
|
|
42
|
+
});
|
|
43
|
+
const cl = scriptClosure(['a.mjs'], { from: src });
|
|
44
|
+
check('S2 node: 内建与长路径字面量被排除', cl.length === 2, cl.join(','));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- S3:注释与字符串里的脚本名不算依赖 ----
|
|
48
|
+
{
|
|
49
|
+
const deps = depsOf(`// 拷 './gone.mjs' 进来\n/* from './gone2.mjs' */\nimport './real.mjs';\n`);
|
|
50
|
+
check('S3 注释里的文件名不算依赖', deps.length === 1 && deps[0] === './real.mjs', JSON.stringify(deps));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- S4:后缀字面量('.test.mjs')不误报——防「每个夹具都误报漏拷」 ----
|
|
54
|
+
// 这条是真实回归:check-loop.mjs 里就有 `.replace(/\.mjs$/, '.test.mjs')`,按名解析必然扑空。
|
|
55
|
+
{
|
|
56
|
+
const deps = depsOf(`f.replace(/\\.mjs$/, '.test.mjs')\nimport './real.mjs';\n`);
|
|
57
|
+
check('S4 后缀字面量 .test.mjs 不计入依赖', !deps.some((d) => d.includes('test')), JSON.stringify(deps));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- S5:spawn 依赖(裸文件名字面量)也进闭包——wiki-search 只靠 path.join(here,'kb-search.mjs') 挂住依赖 ----
|
|
61
|
+
{
|
|
62
|
+
const src = synth({
|
|
63
|
+
'wiki-search.mjs': `spawnSync(process.execPath, [path.join(here, 'kb-search.mjs')]);\n`,
|
|
64
|
+
'kb-search.mjs': `import './workflow-enums.mjs';\n`,
|
|
65
|
+
'workflow-enums.mjs': 'export const a=1;\n',
|
|
66
|
+
});
|
|
67
|
+
const cl = scriptClosure(['wiki-search.mjs'], { from: src });
|
|
68
|
+
check('S5 spawn 裸文件名依赖传递收进闭包', cl.includes('kb-search.mjs') && cl.includes('workflow-enums.mjs'), cl.join(','));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- S6:缺失即炸且报全量清单(非报第一个就停) ----
|
|
72
|
+
{
|
|
73
|
+
const dir = tmpRoot('missing');
|
|
74
|
+
fs.writeFileSync(path.join(dir, 'a.mjs'), `import './m1.mjs';\nimport './m2.mjs';\n`, 'utf8');
|
|
75
|
+
let msg = '';
|
|
76
|
+
try { assertFixtureScripts(dir); } catch (e) { msg = String(e.message); }
|
|
77
|
+
check('S6 缺依赖 → 抛错', !!msg, '未抛错');
|
|
78
|
+
check('S6 报全量缺失清单(m1+m2 都在)', msg.includes('m1.mjs') && msg.includes('m2.mjs'), msg);
|
|
79
|
+
check('S6 报文点明假绿与修法', msg.includes('假绿') && msg.includes('copyScriptClosure'), msg.slice(0, 120));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- S7:copyScriptClosure 对真实盘面跑通(check-loop 闭包 + 拷后自检不炸) ----
|
|
83
|
+
{
|
|
84
|
+
const dest = path.join(tmpRoot('copy'), 'scripts');
|
|
85
|
+
const copied = copyScriptClosure(dest, ['check-loop.mjs']);
|
|
86
|
+
check('S7 check-loop 闭包含新台账依赖', ['check-loop.mjs', 'ledger-loader.mjs', 'ledger-prune.mjs'].every((f) => copied.includes(f)), copied.join(','));
|
|
87
|
+
check('S7 拷贝后 assertFixtureScripts 不炸(闭包自洽)', assertFixtureScripts(dest) === true);
|
|
88
|
+
// 真跑一次被测脚本,确认夹具不是「文件齐了但跑不起来」
|
|
89
|
+
const fx = path.dirname(dest);
|
|
90
|
+
fs.mkdirSync(path.join(fx, '.agents'), { recursive: true });
|
|
91
|
+
fs.copyFileSync(SCRIPT_DIR.replace(/scripts$/, 'workflow-enums.txt'), path.join(fx, '.agents', 'workflow-enums.txt'));
|
|
92
|
+
const { spawnSync } = await import('node:child_process');
|
|
93
|
+
const r = spawnSync(process.execPath, [path.join(dest, 'check-loop.mjs')], {
|
|
94
|
+
cwd: fx, encoding: 'utf8', env: { ...process.env, CHECK_LOOP_ROOT: fx },
|
|
95
|
+
});
|
|
96
|
+
check('S7 夹具内 check-loop 真能跑起来(非 ENOENT 崩)', !/Cannot find module|ERR_MODULE_NOT_FOUND/.test(`${r.stdout || ''}${r.stderr || ''}`), `${r.stdout || ''}${r.stderr || ''}`.slice(0, 200));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- S8:copyAgentsFiles 拷词表,缺失的静默跳过 ----
|
|
100
|
+
{
|
|
101
|
+
const dest = tmpRoot('agents');
|
|
102
|
+
const copied = copyAgentsFiles(dest, ['workflow-enums.txt', 'definitely-absent.txt']);
|
|
103
|
+
check('S8 存在词表拷入、缺席跳过', copied.length === 1 && copied[0] === 'workflow-enums.txt', copied.join(','));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log(`\n[fixture-scripts] PASS ${pass} / FAIL ${fail}`);
|
|
107
|
+
process.exit(fail ? 1 : 0);
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// 测试:node templates/_agents/scripts/gate-checklist.test.mjs(fixture + 真实仓库 baseline)
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
22
23
|
|
|
23
24
|
function fail(msg) { console.error('❌ ' + msg); process.exit(1); }
|
|
24
25
|
|
|
@@ -66,6 +67,7 @@ const PAIRS = [
|
|
|
66
67
|
{ doctor: '6.7', cl: null, note: '跨宿主薄适配——装户体检独有' },
|
|
67
68
|
{ doctor: '6.8', cl: null, note: 'workflows 编排脚本 lint——装户体检独有' },
|
|
68
69
|
{ doctor: '6.9', cl: null, note: '模板下发感知(2026-10-06 template-downstream)——装户体检独有(只读回显 sync 出账同源判据)' },
|
|
70
|
+
{ doctor: '6.10', cl: null, note: 'level-hints 配置体检(2026-10-13 hint-doctor)——装户体检独有;提交面判低由 check-lane-surface(pre-commit 增量面)承担,不在 check-loop 项内' },
|
|
69
71
|
{ doctor: '8', cl: null, note: '看板端口——信息级独有' },
|
|
70
72
|
// 经 §7 覆盖:doctor §7 整体运行 check-loop(#2/#11 已直接配对,不重复登记)
|
|
71
73
|
{ doctor: '7', cl: '1', note: '经 §7 整体运行覆盖' },
|
|
@@ -175,10 +177,11 @@ if (isMain) {
|
|
|
175
177
|
const pkgRoot = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : null;
|
|
176
178
|
const base = pkgRoot || process.cwd();
|
|
177
179
|
const doctorPath = path.join(base, 'src', 'doctor.mjs');
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
+
// check-loop 与本脚本同目录:包源 templates/_agents/scripts/,装户副本 .agents/scripts/。
|
|
181
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
182
|
+
const clPath = path.join(here, 'check-loop.mjs');
|
|
180
183
|
if (!fs.existsSync(doctorPath)) fail('找不到 doctor.mjs:' + doctorPath);
|
|
181
|
-
if (!fs.existsSync(clPath)) fail('找不到 check-loop
|
|
184
|
+
if (!fs.existsSync(clPath)) fail('找不到 check-loop.mjs:' + clPath);
|
|
182
185
|
const doctorSrc = fs.readFileSync(doctorPath, 'utf8');
|
|
183
186
|
const checkLoopSrc = fs.readFileSync(clPath, 'utf8');
|
|
184
187
|
const result = gateChecklist({ doctorSrc, checkLoopSrc });
|
|
@@ -152,11 +152,8 @@ const y = 2;
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
const realDoctor = () => fs.readFileSync(path.join(SRC_ROOT, 'src', 'doctor.mjs'), 'utf8');
|
|
155
|
-
// check-loop
|
|
156
|
-
const realCl = () =>
|
|
157
|
-
const mjs = path.join(SRC_ROOT, '.agents', 'scripts', 'check-loop.mjs');
|
|
158
|
-
return fs.readFileSync(fs.existsSync(mjs) ? mjs : path.join(SRC_ROOT, '.agents', 'scripts', 'check-loop.sh'), 'utf8');
|
|
159
|
-
};
|
|
155
|
+
// check-loop 与 gate-checklist.mjs 同目录(包源 scripts/,拷进装户后是 .agents/scripts/)。
|
|
156
|
+
const realCl = () => fs.readFileSync(path.join(SCRIPT_DIR, 'check-loop.mjs'), 'utf8');
|
|
160
157
|
const hasReal = fs.existsSync(path.join(SRC_ROOT, 'src', 'doctor.mjs'));
|
|
161
158
|
|
|
162
159
|
// ---- S10 真实仓库 baseline:PAIRS 全量登记 → 0 断档 / 0 未登记 ----
|