agentic-flow-kit 1.3.0 → 1.4.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.
@@ -0,0 +1,167 @@
1
+ // check-hygiene.mjs — check-loop 卫生类检查模块(2026-10-08 selfmeasure-and-modularize,L2)
2
+ // 承载 check-loop.mjs 的检查 2 / 9 / 11 / 12 / 13(拆分先例 = check-metric-claims.mjs 承载检查 16):
3
+ // 2. 模板字段占位符残留 [warning]
4
+ // 9. 文件名英文 kebab-case [warning]
5
+ // 11. 生成物漂移(INDEX + DASHBOARD)[warning] ← 本次扩 DASHBOARD(原仅 INDEX)
6
+ // 12. frontmatter「模块:」合法性 [warning]
7
+ // 13. 常驻面体积预算 [warning]
8
+ //
9
+ // 为什么是这五个:它们无跨检查共享状态——判据只依赖 ctx 里既有的 helper(docFiles / linesOf / fmGet /
10
+ // isTracked / readdirOrNull)与 policy,不消费其他检查的中间产物。耦合高的检查 1/5(共享文档循环)、
11
+ // 8(证据核验)、15(指纹对账)留后续批次,拆它们需先解耦,不与本批混(2026-10-08-checkloop-importable
12
+ // 教训:1600+ 行变换与行为修复混批 diff 巨大且不可审)。
13
+ //
14
+ // **判定零复刻铁律**:ctx 里每个 helper 都从 check-loop.mjs 原样传入,模块内**不得**重新实现
15
+ // docFiles / fmGet / linesOf / isTracked——它们各自承载不可漂移的语义(docFiles 的「仓库模式只扫
16
+ // tracked(HEAD)」、fmGet 的 frontmatter 受限子集、linesOf 的读异常响亮出账 + 按空文档降级)。复刻即造口径分叉。
17
+ //
18
+ // 输出契约:本模块**只返回文案数组**,不打印、不 exit、不改 ctx。调用方 `warnings.push(...ret)`——
19
+ // check-loop 在 `audit:false`(init 新装)下把 `warnings.push` 替换为 no-op,卫生项因此不出账(硬规则语义保持)。
20
+ // 检查 11 的生成器 `--check` 口径同样不复刻(调子进程,判据单源在生成器内)。
21
+ //
22
+ // 测试:node templates/_agents/scripts/check-hygiene.test.mjs(fixture root 注入,不触真实 workflow/)
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { gateSeg, finishSegs, makeCollector, appendSegs } from './gate-seg.mjs';
27
+
28
+ // 门禁 ROI 插桩(2026-10-08 gate-roi-metrics):本模块承载的检查 2/9/11/12/13 同样需要计入门禁成本
29
+ // 统计——尤其检查 11(调两个生成器 --check 的 spawn 子进程)与检查 13(调 sh rule-budget.sh)是耗时大户,
30
+ // 不插桩就等于把最贵的两段排除在度量之外。collector 由 check-loop 传入(它持有全局 warnings/blockers),
31
+ // 未传时跳过插桩(纯函数直测场景零副作用)。判据单源见 gate-seg.mjs。
32
+
33
+ export function runCheckHygiene(ctx) {
34
+ const { root: ROOT, WF, DOC_DIRS, docFiles, linesOf, fmGet, isTracked, readdirOrNull, kitPolicy } = ctx;
35
+ const warnings = [];
36
+ // ⚠️ 插桩记在**本模块的局部数组**上,不是 ctx.gateStats 的全局数组——本模块产出的是局部 warnings,
37
+ // 由 check-loop 在返回后才 spread 进全局;执行期间全局长度不动,直接共用游标会导致确定的错账
38
+ // (本模块 5 段恒记 0 命中,全部增量被误记到模块之后的第一个段即检查 3 头上;实测已抓到该症状)。
39
+ // 末尾用 appendSegs 把结算好的段按序并入全局收集器——它会先结算模块前的那一段(如检查 1)再清游标。
40
+ const localStats = makeCollector(warnings, []); // 本模块只产 warnings,不产 blockers
41
+ const markGate = (id, label) => { if (ctx.gateStats) gateSeg(localStats, id, label); };
42
+
43
+ // --- 2. 模板字段占位符残留 [warning](排除运行时文件名格式与协议样板,口径沿 sh 版)---
44
+ markGate('2', '模板字段占位符残留');
45
+ {
46
+ const phRe = /YYYY-MM-DD|<主题>|<日期 主题>|L0 \/ L1 \/ L2 \/ L3|draft \/ approved \/ done|open \/ fixed \/ closed/;
47
+ const boilerRe = /\.\.\/specs\/[A-Za-z0-9-]*\.md|写明如何满足|防复发验证|_YYYY-MM-DD\.|format\('YYYY-MM-DD'\)|value-format="YYYY-MM-DD"|确认结果:approved(YYYY-MM-DD 用户对话内确认);done(YYYY-MM-DD 关单,随入口文档置终态)/;
48
+ // plan 确认节样板豁免(2026-10-03 plan-confirm-boiler):plan 模板「确认与复核」节自带
49
+ // 「确认结果:approved(YYYY-MM-DD 用户对话内确认);done(YYYY-MM-DD 关单,随入口文档置终态)」
50
+ // 样板句,未回填不等于漏填占位(确认由 confirm-doc 机器兜底)——精确豁免整句,真实占位(如
51
+ // `日期: YYYY-MM-DD`)照拦。
52
+ // 命名约定豁免:`<主题>` 与 `.md` 同行 = 在描述文件命名规则(如 `.agents/workflows/<主题>.md`、
53
+ // 「复制本模板为 YYYY-MM-DD-<主题>.md」),非未填占位符;真未填的占位(标题 `# INTENT — <主题>`、
54
+ // `日期: YYYY-MM-DD`)不含 .md,仍照拦(2026-09-25-wf-runtime incident 记录的误报口径)
55
+ const isNamingConv = (line) => line.includes('<主题>') && /\.md/.test(line);
56
+ // 样例引用豁免(2026-10-02 caliber-convergence 假阳性消除,先例=检查 18 行尾归一):命中位于行内代码
57
+ // (反引号包裹)或围栏代码块内 = 引用样例而非未填占位符,不报;正文裸占位符照报——真占位符均为
58
+ // 裸文本(模板正文无反引号包裹),检测面不缩。
59
+ // 日期显示格式豁免(2026-10-06 check2-datetime-literal-exempt):`YYYY-MM-DD HH:mm(:ss)` 是功能
60
+ // 自实现的显示格式描述(文档叙述「匹配时间列显示为 YYYY-MM-DD HH:mm」属常态——cancel-export
61
+ // intent/plan 实证 2 条 advisory、2026-10-06 loop-audit intent 现场复现),非未填占位——行内剔除
62
+ // 后再判;真占位(`日期: YYYY-MM-DD`,无时间粒度后缀)不含该形态仍拦。只豁免实证形态,变体(斜杠日期
63
+ // /十二进制等)出现假阳性再扩。
64
+ const stripSamples = (lines) => {
65
+ let inFence = false;
66
+ return lines.map((line) => {
67
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return ''; }
68
+ if (inFence) return '';
69
+ return line.replace(/`[^`]*`/g, '').replace(/YYYY-MM-DD\s+HH:mm(:ss)?/g, '');
70
+ });
71
+ };
72
+ const groups = new Map(); // file -> [ "行号:内容" ](首现序)
73
+ for (const sub of DOC_DIRS) {
74
+ for (const f of docFiles(sub)) {
75
+ const lines = linesOf(f) || [];
76
+ const hits = [];
77
+ stripSamples(lines).forEach((line, i) => {
78
+ if (phRe.test(line) && !boilerRe.test(line) && !isNamingConv(line)) hits.push(`${i + 1}:${lines[i]}`);
79
+ });
80
+ if (hits.length) groups.set(f, hits);
81
+ }
82
+ }
83
+ for (const [f, hits] of groups) {
84
+ warnings.push(`- [WARN 模板未填] ${path.relative(ROOT, f).split(path.sep).join('/')} 含模板占位符:\n${hits.join('\n')}`);
85
+ }
86
+ }
87
+
88
+ // --- 9. 文件名英文 kebab-case [warning] ---
89
+ markGate('9', '文件名 kebab-case');
90
+ for (const sub of DOC_DIRS) {
91
+ const dir = path.join(ROOT, WF, sub);
92
+ for (const f of readdirOrNull(dir) || []) {
93
+ if (!f.endsWith('.md') || f === '_TEMPLATE.md') continue;
94
+ const abs = path.join(dir, f);
95
+ if (!isTracked(abs)) continue;
96
+ if (!/^[\x20-\x7e]*$/.test(f)) {
97
+ warnings.push(`- [WARN 文件名非英文] 文件名含非 ASCII 字符,应为英文 kebab-case:${WF}/${sub}/${f}`);
98
+ }
99
+ }
100
+ }
101
+
102
+ // --- 11. 生成物漂移 [warning](调生成器 --check,口径单一不复刻渲染)---
103
+ markGate('11', '生成物漂移(INDEX+DASHBOARD)');
104
+ // ⚠️ 位置:本段须排在 12/13 之前——check-loop 输出按 warnings 插入序,行序是稳定输出契约
105
+ // (pre-push / pre-commit 消费者 + doctor 按 WARN 行计数);原序为 2 → 9 → 11 → 12 → 13,勿调换。
106
+ // 两个生成物各自独立出账:漂移来源不同、修复命令不同,混成一条会让装户猜该跑哪个。
107
+ // 装户跳过语义(防常驻噪声,audit-gate-hardening P3 教训):
108
+ // ① 生成器脚本不存在(旧装副本)→ 跳过;
109
+ // ② DASHBOARD.md 不存在(从未生成过)→ 跳过——**新装用户不得因未生成过看板而每次 push 吃 WARN**;
110
+ // (INDEX.md 不设此前置:init 必落该文件,不存在本身即异常)
111
+ for (const [label, genName, docRel] of [
112
+ ['索引漂移', 'gen-workflow-index.mjs', 'workflow/INDEX.md'],
113
+ ['仪表盘漂移', 'gen-workflow-dashboard.mjs', 'workflow/DASHBOARD.md'],
114
+ ]) {
115
+ const gen = path.join(ROOT, '.agents', 'scripts', genName);
116
+ if (!fs.existsSync(gen)) continue;
117
+ if (label === '仪表盘漂移' && !fs.existsSync(path.join(ROOT, docRel))) continue;
118
+ const r = spawnSync(process.execPath, [gen, '--check'], { cwd: ROOT, encoding: 'utf8' });
119
+ if (r.status !== 0) {
120
+ const first = `${r.stdout || ''}${r.stderr || ''}`.split('\n')[0];
121
+ warnings.push(`- [WARN ${label}] ${docRel} 与当前事实不一致——跑 node .agents/scripts/${genName} 重新生成(${first})`);
122
+ }
123
+ }
124
+
125
+ // --- 12. frontmatter「模块:」合法性 [warning](词表单源;缺字段只对 2026-09-22 起新建提示)---
126
+ markGate('12', '模块枚举合法性');
127
+ {
128
+ const modsFile = path.join(ROOT, '.agents', 'workflow-modules.txt');
129
+ if (fs.existsSync(modsFile)) {
130
+ const mods = linesOf(modsFile).filter((l) => l.trim() && !l.startsWith('#')).map((l) => l.trim());
131
+ const vocab = new Set(mods);
132
+ for (const sub of ['intents', 'specs', 'plans', 'incidents']) {
133
+ for (const doc of docFiles(sub)) {
134
+ const base = path.basename(doc);
135
+ const mod = fmGet(doc, '模块');
136
+ let d = fmGet(doc, '日期') || fmGet(doc, '发现');
137
+ if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
138
+ if (mod) {
139
+ if (!vocab.has(mod)) warnings.push(`- [WARN 模块元数据] ${base}「模块: ${mod}」不在词表(见 .agents/workflow-modules.txt)`);
140
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= kitPolicy.moduleSince) {
141
+ warnings.push(`- [WARN 模块元数据] ${base} 缺「模块:」字段(${kitPolicy.moduleSince} 起新建文档必填)`);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ // --- 13. 常驻面体积预算 [warning](判定单源 rule-budget.sh;无 sh 环境静默跳过——advisory,pre-commit 硬拦兜底)---
149
+ markGate('13', '常驻面体积预算');
150
+ {
151
+ const rb = path.join(ROOT, '.agents', 'scripts', 'rule-budget.sh');
152
+ const budgets = path.join(ROOT, '.agents', 'rule-budgets.txt');
153
+ if (fs.existsSync(rb) && fs.existsSync(budgets)) {
154
+ const r = spawnSync('sh', [rb, '--all'], { cwd: ROOT, encoding: 'utf8' });
155
+ if (!r.error && r.status !== 0) {
156
+ const head = `${r.stdout || ''}${r.stderr || ''}`.split('\n').slice(0, 5).map((l) => ' ' + l).join('\n');
157
+ warnings.push(`- [WARN 常驻面超限] 常驻面体积超预算(先删除或下沉被取代条目再增——一进一出):\n${head}`);
158
+ }
159
+ }
160
+ }
161
+
162
+ // 并入全局收集器(仅显式插桩时):把本模块 5 段按序插到「模块前的那一段」之后,
163
+ // 游标由 appendSegs 清空 → check-loop 随后的 markGate('3', …) 从零起算,Δ 天然正确。
164
+ if (ctx.gateStats) appendSegs(ctx.gateStats, finishSegs(localStats));
165
+
166
+ return warnings;
167
+ }
@@ -0,0 +1,182 @@
1
+ // check-hygiene.test.mjs — 卫生类检查模块测试(2026-10-08 selfmeasure-and-modularize,L2)
2
+ // 被测:templates/_agents/scripts/check-hygiene.mjs 的 runCheckHygiene(检查 2 / 9 / 11 / 12 / 13)。
3
+ // 端到端行为回归网仍是 check-loop.test.mjs(219 断言,spawn 真 check-loop)——本套件补的是**模块层直测**:
4
+ // ctx 缺项降级、装户跳过分支、生成物双告警、以及「模块内顺序 = 原 2→9→11→12→13」这条输出契约。
5
+ // fixture:mkdtemp 临时根(workflow/{intents,specs,plans,incidents} + .agents/),**不触真实仓库 workflow/**。
6
+ // 用法:node templates/_agents/scripts/check-hygiene.test.mjs
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, pathToFileURL } from 'node:url';
12
+
13
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
14
+ const { runCheckHygiene } = await import(pathToFileURL(path.join(SCRIPT_DIR, 'check-hygiene.mjs')).href);
15
+
16
+ let pass = 0, fail = 0;
17
+ const check = (name, cond, detail = '') => {
18
+ if (cond) { pass++; console.log(`PASS ${name}`); }
19
+ else { fail++; console.log(`FAIL ${name}${detail ? `——${detail}` : ''}`); }
20
+ };
21
+
22
+ // ctx 组装:复刻 check-loop.mjs 的 helper 口径(tracked 恒真 = fixture 模式、frontmatter 受限子集),
23
+ // 以免测试用自己的一套语义掩盖模块与主文件的漂移。
24
+ function makeCtx(root, overrides = {}) {
25
+ const WF = 'workflow';
26
+ const DOC_DIRS = ['intents', 'specs', 'plans', 'incidents'];
27
+ const fileLinesCache = new Map();
28
+ const linesOf = (file) => {
29
+ if (!fileLinesCache.has(file)) {
30
+ let lines = null;
31
+ try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } catch { lines = null; }
32
+ fileLinesCache.set(file, lines);
33
+ }
34
+ return fileLinesCache.get(file);
35
+ };
36
+ const fmGet = (file, key) => {
37
+ const lines = linesOf(file);
38
+ if (!lines) return '';
39
+ const isDelim = (l) => /^---\s*$/.test(l);
40
+ if (!isDelim(lines[0] || '')) return '';
41
+ for (let i = 1; i < lines.length; i++) {
42
+ if (isDelim(lines[i])) return '';
43
+ if (lines[i].startsWith(key + ':')) return lines[i].slice(key.length + 1).trim();
44
+ }
45
+ return '';
46
+ };
47
+ const isTracked = () => true; // fixture 模式:check-loop 中 CHECK_LOOP_ROOT 注入时恒真
48
+ const docFiles = (sub) => {
49
+ const dir = path.join(root, WF, sub);
50
+ if (!fs.existsSync(dir)) return [];
51
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.md') && /^\d/.test(f))
52
+ .map((f) => path.join(dir, f)).filter(isTracked).sort();
53
+ };
54
+ const readdirOrNull = (dir) => { try { return fs.readdirSync(dir); } catch { return null; } };
55
+ const kitPolicy = { moduleSince: '2026-09-22', ...(overrides.kitPolicy || {}) };
56
+ return { root, WF, DOC_DIRS, docFiles, linesOf, fmGet, isTracked, readdirOrNull, kitPolicy, ...overrides };
57
+ }
58
+
59
+ const W = (root, rel, content) => {
60
+ fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true });
61
+ fs.writeFileSync(path.join(root, rel), content, 'utf8');
62
+ };
63
+ const DOC = (fm, body = '# T\n') => `---\n${fm}\n---\n${body}`;
64
+
65
+ // ---- 检查 2:占位符残留 ----
66
+ {
67
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg2-'));
68
+ W(root, 'workflow/intents/2026-10-01-ph.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline', '# I\n日期: YYYY-MM-DD\n'));
69
+ W(root, 'workflow/intents/2026-10-01-ok.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline', '# I\n没有占位符\n'));
70
+ W(root, 'workflow/intents/2026-10-01-fence.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline', '# I\n```\nYYYY-MM-DD\n```\n'));
71
+ const r = runCheckHygiene(makeCtx(root));
72
+ const ph = r.filter((w) => w.includes('[WARN 模板未填]'));
73
+ check('检查 2:裸占位符出账 + 围栏代码块内豁免(仅 1 件)',
74
+ ph.length === 1 && ph[0].includes('2026-10-01-ph.md'), JSON.stringify(r));
75
+ fs.rmSync(root, { recursive: true, force: true });
76
+ }
77
+
78
+ // ---- 检查 9:文件名 kebab(检查 9 走 readdirOrNull,不走 docFiles 的 /^\d/ 过滤)----
79
+ {
80
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg9-'));
81
+ W(root, 'workflow/intents/2026-10-01-中文名.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline'));
82
+ const r = runCheckHygiene(makeCtx(root));
83
+ check('检查 9:非 ASCII 文件名出账', r.some((w) => w.includes('[WARN 文件名非英文]') && w.includes('中文名.md')), JSON.stringify(r));
84
+ fs.rmSync(root, { recursive: true, force: true });
85
+ }
86
+
87
+ // ---- 检查 11:双生成物 + 装户跳过 ----
88
+ {
89
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg11-'));
90
+ // 装户形态:无生成器脚本 → 检查 11 整体跳过(不报)
91
+ let r = runCheckHygiene(makeCtx(root));
92
+ check('检查 11:生成器脚本不存在(旧装副本)→ 跳过不报',
93
+ !r.some((w) => w.includes('漂移]')), JSON.stringify(r));
94
+ // 放一个恒红的假生成器(--check 必 exit 1),覆盖双告警
95
+ W(root, '.agents/scripts/gen-workflow-index.mjs', 'process.exit(1);\n');
96
+ W(root, '.agents/scripts/gen-workflow-dashboard.mjs', 'process.exit(1);\n');
97
+ W(root, 'workflow/INDEX.md', '# stale\n');
98
+ W(root, 'workflow/DASHBOARD.md', '# stale\n'); // 双告警场景须盘面在位——否则命中装户跳过分支
99
+ r = runCheckHygiene(makeCtx(root));
100
+ check('检查 11:INDEX + DASHBOARD 各自独立出账且指名修复命令',
101
+ r.some((w) => w.includes('[WARN 索引漂移]') && w.includes('gen-workflow-index.mjs'))
102
+ && r.some((w) => w.includes('[WARN 仪表盘漂移]') && w.includes('gen-workflow-dashboard.mjs')), JSON.stringify(r));
103
+ // DASHBOARD.md 不存在 → 只剩 INDEX 一条(装户未生成过看板不得常红)
104
+ fs.rmSync(path.join(root, 'workflow/DASHBOARD.md'), { force: true });
105
+ r = runCheckHygiene(makeCtx(root));
106
+ check('检查 11:DASHBOARD.md 缺失 → 跳过(装户未生成过看板不常红)',
107
+ !r.some((w) => w.includes('仪表盘漂移]')) && r.some((w) => w.includes('索引漂移]')), JSON.stringify(r));
108
+ fs.rmSync(root, { recursive: true, force: true });
109
+ }
110
+
111
+ // ---- 检查 12:模块枚举 + 词表缺失降级 ----
112
+ {
113
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg12-'));
114
+ W(root, '.agents/workflow-modules.txt', '# 词表\npipeline\nwiki\n');
115
+ W(root, 'workflow/intents/2026-10-01-bad.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: 不存在的模块'));
116
+ W(root, 'workflow/intents/2026-10-01-missing.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01'));
117
+ W(root, 'workflow/intents/2026-09-01-old.md', DOC('状态: done\n级别: L1\n日期: 2026-09-01')); // 早于 moduleSince → 存量豁免
118
+ let r = runCheckHygiene(makeCtx(root));
119
+ check('检查 12:非法模块出账 + 新建缺字段出账 + 存量豁免',
120
+ r.some((w) => w.includes('不在词表')) && r.some((w) => w.includes('missing.md 缺「模块:」字段'))
121
+ && !r.some((w) => w.includes('old.md')), JSON.stringify(r));
122
+ fs.rmSync(path.join(root, '.agents/workflow-modules.txt'));
123
+ r = runCheckHygiene(makeCtx(root));
124
+ check('检查 12:词表文件缺失 → 静默跳过(装户无感)', !r.some((w) => w.includes('模块元数据')), JSON.stringify(r));
125
+ fs.rmSync(root, { recursive: true, force: true });
126
+ }
127
+
128
+ // ---- 输出顺序契约:块内须保持 2 → 9 → 11 → 12 → 13 ----
129
+ {
130
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg-order-'));
131
+ W(root, 'workflow/intents/2026-10-01-ph.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: 非法模块', '# I\nYYYY-MM-DD\n'));
132
+ W(root, 'workflow/intents/2026-10-01-中文名.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline'));
133
+ W(root, '.agents/scripts/gen-workflow-index.mjs', 'process.exit(1);\n');
134
+ W(root, '.agents/scripts/gen-workflow-dashboard.mjs', 'process.exit(1);\n');
135
+ W(root, '.agents/workflow-modules.txt', '# 词表\npipeline\n'); // 词表须在位,否则检查 12 整段跳过
136
+ W(root, 'workflow/INDEX.md', '# stale\n');
137
+ W(root, 'workflow/DASHBOARD.md', '# stale\n');
138
+ const r = runCheckHygiene(makeCtx(root));
139
+ const tags = r.map((w) => (w.includes('模板未填') ? '2' : w.includes('文件名非英文') ? '9'
140
+ : w.includes('索引漂移') ? '11a' : w.includes('仪表盘漂移') ? '11b' : w.includes('模块元数据') ? '12' : '13'));
141
+ check('输出顺序:块内 2 → 9 → 11 → 12 保持(行序属稳定输出契约)',
142
+ tags.join(',') === '2,9,11a,11b,12', tags.join(','));
143
+ fs.rmSync(root, { recursive: true, force: true });
144
+ }
145
+
146
+ // ---- 检查 20 覆盖:本模块须被 npm test 的 glob 发现 ----
147
+ {
148
+ const found = fs.existsSync(path.join(SCRIPT_DIR, 'check-hygiene.test.mjs'));
149
+ check('检查 20:本套件与被测模块同名兄弟(否则检查 20 出账)', found);
150
+ const r = spawnSync(process.execPath, ['--check', path.join(SCRIPT_DIR, 'check-hygiene.mjs')], { encoding: 'utf8' });
151
+ check('被测模块语法有效', r.status === 0, r.stderr || '');
152
+ }
153
+
154
+ // ---- 门禁 ROI 段归属(2026-10-08 gate-roi-metrics,实仓抓到的错账根因)----
155
+ // 症状:本模块的 warnings 是**局部数组**(check-loop 事后才 spread 进全局),若段增量记在全收集器上,
156
+ // 本模块 5 段恒记 0 命中、且全部增量被误记到模块之后的第一个段(检查 3)。修复 = 局部收集器 + appendSegs。
157
+ // 本组断言在**模块层**钉死归属:检查 2 与检查 9 各产 1 条,必须各归各的段,不得漂到别的段上。
158
+ {
159
+ const { makeCollector, finishSegs } = await import(pathToFileURL(path.join(SCRIPT_DIR, 'gate-seg.mjs')).href);
160
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hygroi-'));
161
+ W(root, 'workflow/intents/2026-10-01-ph.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline', '# I\n日期: YYYY-MM-DD\n'));
162
+ W(root, 'workflow/intents/2026-10-01-中文名.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline'));
163
+ // 全局收集器挂在**空数组**上:若模块错误地用全局数组做 Δ 源,两条警告一条都不会被算进去。
164
+ const globalStats = makeCollector([], []);
165
+ const r = runCheckHygiene(makeCtx(root, { gateStats: globalStats }));
166
+ const segs = globalStats.segs;
167
+ const byId = new Map(segs.map((s) => [s.id, s.warns]));
168
+ const hits = r.length;
169
+ check('段归属:本模块 5 段全部入账(2/9/11/12/13)',
170
+ segs.map((s) => s.id).join(',') === '2,9,11,12,13', JSON.stringify(segs.map((s) => s.id)));
171
+ check('段归属:各段增量之和 == 本模块实际产出条数(无漏记无重复记)',
172
+ segs.reduce((a, s) => a + s.warns, 0) === hits && hits === 2, `segs=${JSON.stringify(segs)} hits=${hits}`);
173
+ check('段归属:检查 2 / 检查 9 各归 1 条(**不都堆到末段**——本次实抓的错账形态)',
174
+ byId.get('2') === 1 && byId.get('9') === 1 && byId.get('11') === 0 && byId.get('12') === 0 && byId.get('13') === 0,
175
+ JSON.stringify(segs));
176
+ check('段归属:并入后全局游标清空(check-loop 随后的 markGate 从零起算)', globalStats.current === null);
177
+ check('段归属:finishSegs(全局) 幂等,不产生额外伪段', finishSegs(globalStats).length === segs.length);
178
+ fs.rmSync(root, { recursive: true, force: true });
179
+ }
180
+
181
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
182
+ process.exit(fail ? 1 : 0);