agentic-flow-kit 1.6.0 → 1.7.15
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 +40 -6
- 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 +2 -2
- package/modules/hosts/claude/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/claude/commands/wf-plan.md +1 -1
- package/modules/hosts/claude/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/claude/commands/wf-sync-hosts.md +1 -3
- 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 +2 -2
- package/modules/hosts/codex/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/codex/commands/wf-plan.md +1 -1
- package/modules/hosts/codex/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/codex/commands/wf-sync-hosts.md +1 -3
- 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 +2 -2
- package/modules/hosts/cursor/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/cursor/commands/wf-plan.md +1 -1
- package/modules/hosts/cursor/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/cursor/commands/wf-sync-hosts.md +1 -3
- 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 +2 -2
- package/modules/hosts/opencode/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/opencode/commands/wf-plan.md +1 -1
- package/modules/hosts/opencode/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/opencode/commands/wf-sync-hosts.md +1 -3
- 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 +2 -2
- package/modules/hosts/trae/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/trae/commands/wf-plan.md +1 -1
- package/modules/hosts/trae/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/trae/commands/wf-sync-hosts.md +1 -3
- 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 +17 -2
- package/src/doctor.mjs +99 -11
- package/src/gates.test.mjs +17 -0
- package/src/init.mjs +7 -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/render.mjs +5 -0
- package/src/rule-budget.test.mjs +103 -0
- package/src/run-tests.mjs +16 -9
- package/src/sync-hosts.mjs +4 -2
- package/src/trae-hooks.test.mjs +15 -3
- package/src/validate.mjs +72 -0
- package/src/validate.test.mjs +95 -0
- package/templates/AGENTS.md +3 -1
- package/templates/_agents/commands/build.md +5 -24
- package/templates/_agents/commands/deploy.md +1 -0
- package/templates/_agents/commands/design.md +2 -2
- package/templates/_agents/commands/gate-checklist.md +1 -3
- package/templates/_agents/commands/plan.md +1 -1
- package/templates/_agents/commands/source-sync-check.md +1 -3
- package/templates/_agents/commands/sync-hosts.md +1 -3
- package/templates/_agents/commands/test.md +5 -4
- package/templates/_agents/control-surfaces.txt +53 -0
- package/templates/_agents/scripts/agg-delegations.cjs +51 -6
- package/templates/_agents/scripts/agg-delegations.test.mjs +79 -1
- package/templates/_agents/scripts/check-engine-integrity.mjs +288 -0
- package/templates/_agents/scripts/check-engine-integrity.test.mjs +227 -0
- package/templates/_agents/scripts/check-lane-surface.mjs +7 -2
- package/templates/_agents/scripts/check-ledger-invariant.mjs +18 -0
- package/templates/_agents/scripts/check-ledger-invariant.test.mjs +16 -0
- package/templates/_agents/scripts/check-ledger.test.mjs +2 -0
- package/templates/_agents/scripts/check-loop.mjs +247 -5
- package/templates/_agents/scripts/check-loop.test.mjs +367 -2
- package/templates/_agents/scripts/confirm-doc.mjs +50 -3
- package/templates/_agents/scripts/confirm-doc.test.mjs +93 -1
- package/templates/_agents/scripts/doctor.test.mjs +251 -1
- package/templates/_agents/scripts/draft-sediment.mjs +178 -0
- package/templates/_agents/scripts/draft-sediment.test.mjs +272 -0
- package/templates/_agents/scripts/gate-checklist.mjs +10 -2
- package/templates/_agents/scripts/gate-checklist.test.mjs +7 -1
- 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 +2 -2
- package/templates/_agents/scripts/gen-workflow-metrics.mjs +11 -5
- package/templates/_agents/scripts/kb-search.mjs +87 -12
- package/templates/_agents/scripts/kb-search.test.mjs +83 -0
- package/templates/_agents/scripts/pipeline-run.mjs +1 -1
- package/templates/_agents/scripts/policy.mjs +49 -0
- package/templates/_agents/scripts/solidify-task.mjs +1 -1
- package/templates/_agents/scripts/source-sync-check.test.mjs +9 -4
- package/templates/_agents/scripts/stage-gates.mjs +48 -1
- 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 +12 -4
- package/templates/_agents/scripts/verify.test.mjs +30 -3
- package/templates/_agents/scripts-test-exempt.txt +3 -1
- package/templates/_agents/skills/wiki/SKILL.md +5 -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/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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// add-gate.test.mjs — gate 防改锚(2026-10-11 P3-1)装户端到端测试。
|
|
3
|
+
// 手法:真实 bin/flow-kit.mjs init 建临时装户(fresh-init.test.mjs 同款)→ CLI add-gate test-protect,
|
|
4
|
+
// 断言 kit.json owned:逐字拷贝的 gate 脚本带 srcSha256=安装时刻包源文件 LF 归一 sha;
|
|
5
|
+
// local-pre-commit(合并接线件,非逐字拷贝)不写 srcSha256。再覆盖三条锚路径——
|
|
6
|
+
// ①幂等重跑(盘面=包源)安静且锚不动;②盘面手改后重跑(未 --force)WARN 差异 + 原锚保留
|
|
7
|
+
// (doctor checkGateAnchors 端到端抓到手改);③--force 重装重锚。
|
|
8
|
+
// 用法:node src/add-gate.test.mjs
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const CLI = path.join(ROOT, 'bin', 'flow-kit.mjs');
|
|
18
|
+
const GATE = 'test-protect';
|
|
19
|
+
const GATE_REL = `.agents/hooks/check-test-protect.mjs`;
|
|
20
|
+
const LP_REL = '.agents/hooks/local-pre-commit';
|
|
21
|
+
const PKG_GATE_SRC = path.join(ROOT, 'modules', 'gates', GATE, 'check-test-protect.mjs');
|
|
22
|
+
|
|
23
|
+
// checkGateAnchors 端到端联动:add-gate 写下的锚正是 doctor 锚校验读取的判据(耦合面单独断言)
|
|
24
|
+
const { checkGateAnchors } = await import(pathToFileURL(path.join(ROOT, 'src', 'doctor.mjs')).href);
|
|
25
|
+
|
|
26
|
+
let pass = 0;
|
|
27
|
+
let fail = 0;
|
|
28
|
+
const check = (name, cond, detail = '') => {
|
|
29
|
+
if (cond) { pass++; console.log(`PASS ${name}`); }
|
|
30
|
+
else { fail++; console.log(`FAIL ${name}${detail ? `\n ${detail}` : ''}`); }
|
|
31
|
+
};
|
|
32
|
+
// LF 归一 sha(与 add-gate shaText / doctor checkGateAnchors 同口径,跨 checkout 字节稳定)
|
|
33
|
+
const shaOf = (p) => createHash('sha256').update(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8').digest('hex');
|
|
34
|
+
const R = (p) => fs.readFileSync(p, 'utf8');
|
|
35
|
+
|
|
36
|
+
// ---- 建临时装户:git init + 真实 CLI init(fresh-init.test.mjs 同款)----
|
|
37
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-addgate-'));
|
|
38
|
+
spawnSync(process.platform === 'win32' ? 'git.exe' : 'git', ['init', '-q'], { cwd: dir });
|
|
39
|
+
const init = spawnSync(process.execPath, [CLI, 'init', '--stack', 'none', '--hosts', 'claude', '--board-port', '8941', '--dir', dir], { encoding: 'utf8', timeout: 120000 });
|
|
40
|
+
if (init.status !== 0) {
|
|
41
|
+
console.error(`add-gate.test.mjs:前置 init 失败无法继续\n${(init.stdout || '') + (init.stderr || '')}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const kitOf = () => JSON.parse(R(path.join(dir, '.agents', 'kit.json')));
|
|
45
|
+
const gateEntry = () => kitOf().owned.find((f) => f.rel === GATE_REL);
|
|
46
|
+
|
|
47
|
+
// ---- 场景 1:首装——逐字拷贝件写锚=包源 sha;local-pre-commit 不写锚 ----
|
|
48
|
+
{
|
|
49
|
+
const r = spawnSync(process.execPath, [CLI, 'add-gate', GATE, '--dir', dir], { encoding: 'utf8' });
|
|
50
|
+
const out = r.stdout + r.stderr;
|
|
51
|
+
check('S1 add-gate exit 0', r.status === 0, `exit=${r.status}\n${out.slice(-400)}`);
|
|
52
|
+
const gate = gateEntry();
|
|
53
|
+
check('S1 gate 脚本带 srcSha256=包源文件 LF 归一 sha(安装时刻包源)', !!gate && gate.srcSha256 === shaOf(PKG_GATE_SRC), JSON.stringify(gate || null));
|
|
54
|
+
check('S1 gate 台账 sha256=盘面(LF 归一记账)', !!gate && gate.sha256 === shaOf(path.join(dir, GATE_REL)), JSON.stringify(gate || null));
|
|
55
|
+
check('S1 门禁脚本落盘且与包源一致(逐字拷贝)', R(path.join(dir, GATE_REL)) === R(PKG_GATE_SRC));
|
|
56
|
+
const lp = kitOf().owned.find((f) => f.rel === LP_REL);
|
|
57
|
+
check('S1 local-pre-commit(合并接线件)不写 srcSha256', !!lp && lp.srcSha256 === undefined, JSON.stringify(lp || null));
|
|
58
|
+
check('S1 local-pre-commit 台账 sha256 刷为接线后盘面', !!lp && lp.sha256 === shaOf(path.join(dir, LP_REL)), JSON.stringify(lp || null));
|
|
59
|
+
check('S1 首装输出无盘面/包源差异 WARN', !out.includes('盘面与包源不一致'), out);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- 场景 2:幂等重跑(未 --force、盘面=包源)——安静,条目原样 ----
|
|
63
|
+
{
|
|
64
|
+
const before = JSON.stringify(gateEntry());
|
|
65
|
+
const r = spawnSync(process.execPath, [CLI, 'add-gate', GATE, '--dir', dir], { encoding: 'utf8' });
|
|
66
|
+
const out = r.stdout + r.stderr;
|
|
67
|
+
check('S2 幂等重跑 exit 0 且无差异 WARN', r.status === 0 && !out.includes('盘面与包源不一致'), out);
|
|
68
|
+
check('S2 锚与台账条目原样', JSON.stringify(gateEntry()) === before, `${before} → ${JSON.stringify(gateEntry())}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- 场景 3:盘面手改后重跑(未 --force)——WARN 差异可见,原锚保留(手改永久留痕)----
|
|
72
|
+
{
|
|
73
|
+
fs.writeFileSync(path.join(dir, GATE_REL), `${R(PKG_GATE_SRC)}\n// 本地手改:放宽红线(测试标记)\n`);
|
|
74
|
+
const before = gateEntry();
|
|
75
|
+
const r = spawnSync(process.execPath, [CLI, 'add-gate', GATE, '--dir', dir], { encoding: 'utf8' });
|
|
76
|
+
const out = r.stdout + r.stderr;
|
|
77
|
+
const after = gateEntry();
|
|
78
|
+
check('S3 手改后重跑出 WARN(盘面与包源不一致,含文件名)', out.includes('盘面与包源不一致') && out.includes('check-test-protect.mjs'), out);
|
|
79
|
+
check('S3 已有台账条目 → WARN 指明原锚保留', out.includes('原锚保留'), out);
|
|
80
|
+
check('S3 原锚保留(未被盘面重锚——重锚会把手改吸收成无差异)', after.srcSha256 === before.srcSha256 && after.srcSha256 === shaOf(PKG_GATE_SRC), `${JSON.stringify(before)} → ${JSON.stringify(after)}`);
|
|
81
|
+
check('S3 台账 sha256 亦不被改写(未 --force 不动既有条目)', after.sha256 === before.sha256, `${JSON.stringify(before)} → ${JSON.stringify(after)}`);
|
|
82
|
+
check('S3 手改盘面幸存(未 --force 不覆盖)', R(path.join(dir, GATE_REL)).includes('本地手改'), R(path.join(dir, GATE_REL)).slice(-120));
|
|
83
|
+
const d = checkGateAnchors(dir);
|
|
84
|
+
check('S3 doctor 锚校验按 add-gate 写下的锚抓到手改(端到端)', d.skipped === false && d.mismatch.includes(GATE_REL), JSON.stringify(d));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- 场景 4:--force 重装——盘面恢复包源 + 锚随新安装刷新(重锚路径)----
|
|
88
|
+
{
|
|
89
|
+
const r = spawnSync(process.execPath, [CLI, 'add-gate', GATE, '--dir', dir, '--force'], { encoding: 'utf8' });
|
|
90
|
+
const out = r.stdout + r.stderr;
|
|
91
|
+
const gate = gateEntry();
|
|
92
|
+
check('S4 --force 重装恢复包源内容', R(path.join(dir, GATE_REL)) === R(PKG_GATE_SRC));
|
|
93
|
+
check('S4 锚随新安装刷新(srcSha256=包源 sha)', !!gate && gate.srcSha256 === shaOf(PKG_GATE_SRC), JSON.stringify(gate || null));
|
|
94
|
+
check('S4 台账 sha256 回到包面值', !!gate && gate.sha256 === shaOf(PKG_GATE_SRC), JSON.stringify(gate || null));
|
|
95
|
+
check('S4 输出含安装报告', out.includes('安装到'), out);
|
|
96
|
+
const d = checkGateAnchors(dir);
|
|
97
|
+
check('S4 重锚后 doctor 锚校验回到无失配', d.skipped === false && d.mismatch.length === 0 && d.gone.length === 0 && d.total >= 1, JSON.stringify(d));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
|
|
102
|
+
console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
|
|
103
|
+
process.exit(fail ? 1 : 0);
|
package/src/cli.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// flow-kit CLI 入口:init(安装)/ sync(升级)/ add-host(补宿主)/ add-gate(装门禁)/ doctor(体检)/ sync-hosts(跨宿主同步)/ help
|
|
2
|
+
// flow-kit CLI 入口:init(安装)/ sync(升级)/ add-host(补宿主)/ add-gate(装门禁)/ doctor(体检)/ validate(只读校验)/ sync-hosts(跨宿主同步)/ help
|
|
3
3
|
import { init } from './init.mjs';
|
|
4
4
|
import { doctor } from './doctor.mjs';
|
|
5
|
+
import { validate } from './validate.mjs';
|
|
5
6
|
import { sync } from './sync.mjs';
|
|
6
7
|
import { syncHosts } from './sync-hosts.mjs';
|
|
7
8
|
import { addHost } from './add-host.mjs';
|
|
@@ -39,6 +40,10 @@ export const HELP = `flow-kit — AI-Native 闭环工作流 + wiki 知识层脚
|
|
|
39
40
|
--apply 单向同步(权威→薄适配,不反向避免污染);
|
|
40
41
|
改一处权威源要同步 N 份薄适配,人工 grep 易漏
|
|
41
42
|
flow-kit doctor 体检:目录布局 / git 钩子 / managed 清单 / 索引漂移 / 跨宿主漂移 / check-loop
|
|
43
|
+
flow-kit validate 只读校验:包内置 check-loop 引擎对装户盘面跑闭环 20 项检查(不执行装户侧脚本,
|
|
44
|
+
不改文档台账);明细走 stderr,stdout 承载汇总——0/1 退出码可作 CI 独立一道门。
|
|
45
|
+
与 doctor 的分工:doctor 查「装得健不健康」(含装户侧引擎校验后执行),
|
|
46
|
+
validate 查「盘面内容合不合规」(判定引擎随包版本走)
|
|
42
47
|
|
|
43
48
|
init 选项(全部可选,均有默认值):
|
|
44
49
|
--stack <技术栈> dotnet | node(ts/js) | python | go | none(默认 none;ts/js/typescript/javascript
|
|
@@ -52,6 +57,7 @@ init 选项(全部可选,均有默认值):
|
|
|
52
57
|
--force 覆盖已存在的同名文件(默认保守跳过)
|
|
53
58
|
|
|
54
59
|
sync / add-host 选项:--dir <目录>、--force(覆盖本地已改 / 已装内容);add-gate 选项:--dir <目录>、--force。
|
|
60
|
+
validate 选项:--strict(advisory warning 也判红)/ --json(机器可读,stdout)/ --dir <目录>(目标项目根,默认当前目录)。
|
|
55
61
|
sync-hosts 选项:--diff(默认,仅报告,不修改)/ --apply(单向同步到薄适配)/ --json(机器可读输出)/ --dir <目录>(目标项目根,默认当前目录)。
|
|
56
62
|
sync-hosts 双布局(2026-10-07):包源仓(含 templates/_agents + modules/hosts)比对 templates/_agents ↔ modules/hosts;
|
|
57
63
|
装户仓(含 .agents)比对 .agents ↔ 各宿主目录(.opencode/.trae/.omp/.zcode…)——同一命令两处都可用。
|
|
@@ -62,10 +68,12 @@ sync-hosts 双布局(2026-10-07):包源仓(含 templates/_agents + modul
|
|
|
62
68
|
npx agentic-flow-kit sync # 包出新版后升级(本地改过的 managed 文件会跳过并报告)
|
|
63
69
|
npx agentic-flow-kit add-host opencode # 后补宿主
|
|
64
70
|
npx agentic-flow-kit add-gate dotnet-ca # 装 Clean Architecture 门禁
|
|
71
|
+
npx agentic-flow-kit doctor # 体检
|
|
72
|
+
npx agentic-flow-kit validate --strict # 只读结构校验(CI 可用,0/1 退出码)
|
|
65
73
|
npx agentic-flow-kit sync-hosts --diff # 看权威源 vs 薄适配漂移
|
|
66
74
|
npx agentic-flow-kit sync-hosts --apply # 单向同步到薄适配
|
|
67
75
|
|
|
68
|
-
|
|
76
|
+
装完本地可跑:日常闭环走项目内 .agents/scripts/(零 npm 运行时依赖);升级 / 体检 / 装门禁用 npx agentic-flow-kit(sync / doctor / add-host / add-gate);AGENTS.md 生成「AI工作流 + Wiki」骨架,项目细节在「项目适配区」自填。`;
|
|
69
77
|
|
|
70
78
|
export function run(argv) {
|
|
71
79
|
const cmd = argv[0];
|
|
@@ -106,6 +114,13 @@ export function run(argv) {
|
|
|
106
114
|
doctor(argv.slice(1), PKG_ROOT);
|
|
107
115
|
return;
|
|
108
116
|
}
|
|
117
|
+
if (cmd === 'validate') {
|
|
118
|
+
validate(argv.slice(1), PKG_ROOT).catch((e) => {
|
|
119
|
+
console.error(`❌ ${e?.message || e}`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
109
124
|
console.error(`未知命令:${cmd}\n\n${HELP}`);
|
|
110
125
|
process.exit(1);
|
|
111
126
|
}
|
package/src/doctor.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import net from 'node:net';
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { listTree, scriptTrusted } from './render.mjs';
|
|
8
8
|
import { isOwned, HOSTS, pickStackVars, srcTemplatePath, templateDriftOf, TEMPLATE_DRIFT_EXCLUDE } from './profiles.mjs';
|
|
9
|
+
import { bodySha } from './sync-hosts.mjs';
|
|
9
10
|
|
|
10
11
|
// 宿主目录映射(与 src/profiles.mjs#HOSTS 同源:装副本目录名前缀带点)
|
|
11
12
|
const HOST_DIR = Object.fromEntries(Object.entries(HOSTS).map(([k, v]) => [k, v.dir]));
|
|
@@ -178,6 +179,20 @@ export function doctor(args, pkgRoot) {
|
|
|
178
179
|
else add('WARN', `workflow/INDEX.md 漂移——跑 node .agents/scripts/gen-workflow-index.mjs 重生成`);
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
// 6b. wiki 索引漂移(2026-10-10:镜像 §6 workflow 侧——wiki INDEX 计数/映射表/看板 DATA 原本无任何
|
|
183
|
+
// 机器校验,装户手改生成区或忘跑 gen-wiki-board 只有肉眼能发现;workflow 侧有 doctor + check-loop 11
|
|
184
|
+
// 双路,wiki 侧此前是单路都没有)。存在性先行:旧装户无此脚本 = 既有「静默跳过」语义(同 §6.5 口径)
|
|
185
|
+
if (fs.existsSync(path.join(target, '.agents/scripts/gen-wiki-board.mjs'))) {
|
|
186
|
+
const wikiGuard = scriptGuard('.agents/scripts/gen-wiki-board.mjs');
|
|
187
|
+
if (!wikiGuard.ok) {
|
|
188
|
+
add('WARN', `wiki/INDEX.md 漂移检查跳过——${wikiGuard.note}(供应链防线)`);
|
|
189
|
+
} else {
|
|
190
|
+
const wb = spawnSync(process.execPath, ['.agents/scripts/gen-wiki-board.mjs', '--check'], { cwd: target, encoding: 'utf8' });
|
|
191
|
+
if (wb.status === 0) add('PASS', 'wiki/INDEX.md 与看板无漂移');
|
|
192
|
+
else add('WARN', `wiki/INDEX.md 或看板漂移——跑 node .agents/scripts/gen-wiki-board.mjs 重生成`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
181
196
|
// 6.5 delegations 台账结构(量化层非门禁:结构漂移曾静默吞掉全部记录,2026-09-24)
|
|
182
197
|
// 存在性先行(复核 P2-2):旧装户无此脚本 = 既有「静默跳过」语义,不产生防线 WARN 噪音
|
|
183
198
|
if (fs.existsSync(path.join(target, '.agents/scripts/agg-delegations.cjs'))) {
|
|
@@ -214,11 +229,35 @@ export function doctor(args, pkgRoot) {
|
|
|
214
229
|
add('PASS', `owned ${ownedRes.total} 份无漂移(装副本与台账 sha 对齐)`);
|
|
215
230
|
} else {
|
|
216
231
|
// owned 漂移已严化为 FAIL(2026-09-25 doctor-owned-drift-strict 复盘;首次引入用 WARN,installed 装户吃过警告后升级)
|
|
217
|
-
if (ownedRes.drift) add('FAIL', `owned 漂移 ${ownedRes.drift} 份——装副本手改未跑 sync 刷台账 / 包源改了装副本未同步;须手动同步装副本后跑
|
|
232
|
+
if (ownedRes.drift) add('FAIL', `owned 漂移 ${ownedRes.drift} 份——装副本手改未跑 sync 刷台账 / 包源改了装副本未同步;须手动同步装副本后跑 npx agentic-flow-kit sync 刷台账`);
|
|
218
233
|
if (ownedRes.gone.length) add('FAIL', `owned 文件缺失:${ownedRes.gone.join('、')}——sync 恢复或手动恢复`);
|
|
219
234
|
}
|
|
220
235
|
|
|
221
|
-
// 6.
|
|
236
|
+
// 6.6b gate 安装锚校验(2026-10-11 P3-1 gate 防改锚):add-gate 给逐字拷贝安装的 gate 脚本写
|
|
237
|
+
// srcSha256=安装时刻包源 sha(见 src/add-gate.mjs 头注);sync 只按包源刷新模板条目锚,gate 条目
|
|
238
|
+
// 解析不到包源模板(srcTemplatePath→null 提前 return)故锚恒定 → 盘面 sha ≠ 锚即「装后手改」。
|
|
239
|
+
// 设计依据:2026-10-10 调研结论「gate 装户可改、hard 项缺防改」——sync 对 owned 的 sha256 按盘面
|
|
240
|
+
// 自愈使手改不留痕,本项补上可见性。不阻断(沿用「先 WARN 升级 FAIL」渐进路径):装户对门有最终
|
|
241
|
+
// 所有权,锚只提供可见性,有意改动知悉即可。
|
|
242
|
+
const gateRes = checkGateAnchors(target);
|
|
243
|
+
// 出账分级按信任档(2026-10-12 gate-trust-level):project 档 = WARN 永久可见(装户对门有最终
|
|
244
|
+
// 所有权);org 档 = FAIL(doctor exit 1,组织级强制位)。两种档位的**文案主干逐字相同**——
|
|
245
|
+
// 升档只改级别与追加一句「本仓已声明 org 档」,不改判据、不改修复指引(作者照同一套指引修即可)。
|
|
246
|
+
const orgMode = gateRes.trust === 'org';
|
|
247
|
+
const trustNote = orgMode ? '【本仓 kit.json gateTrust=org:组织级强制档,失配属 FAIL】' : '【本仓 kit.json gateTrust=project:项目自治档,失配属 WARN;改 org 可升级为强制】';
|
|
248
|
+
if (gateRes.error) {
|
|
249
|
+
add('WARN', `gate 安装锚校验跳过(kit.json 解析失败:${gateRes.error})`);
|
|
250
|
+
} else if (gateRes.skipped) {
|
|
251
|
+
add('PASS', `gate 安装锚校验跳过(无带锚 gate 条目——旧装户重装 add-gate 后生效)`);
|
|
252
|
+
} else if (gateRes.mismatch.length === 0 && gateRes.gone.length === 0) {
|
|
253
|
+
add('PASS', `gate 安装锚 ${gateRes.total} 份无失配`);
|
|
254
|
+
} else {
|
|
255
|
+
const lv = orgMode ? 'FAIL' : 'WARN';
|
|
256
|
+
if (gateRes.mismatch.length) add(lv, `gate 安装锚失配 ${gateRes.mismatch.length} 份:${gateRes.mismatch.join('、')}——.agents/hooks/ 门禁脚本与安装时包源 sha 不一致(手改会永久留痕)${orgMode ? '(org 档即不可接受)' : ';有意改动知悉即可'}(重装重锚:flow-kit add-gate <门名> --force;恢复原版:从包源 modules/gates/ 拷回)${trustNote}`);
|
|
257
|
+
if (gateRes.gone.length) add(lv, `gate 带锚文件缺失 ${gateRes.gone.length} 份:${gateRes.gone.join('、')}——门禁已不在盘面(失效${orgMode ? ';org 档即不可接受' : ''});恢复:flow-kit add-gate <门名> --force 或从包源 modules/gates/ 拷回${trustNote}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 6.7 跨宿主薄适配正文段漂移校验(2026-09-25 cross-host-sync 立法)
|
|
222
261
|
// 装户侧视角:.agents/{commands,roles}/*.md 是权威源;4 宿主目录(.zcode/.omp/.opencode/.trae)下的
|
|
223
262
|
// {agents,commands}/*.md 是薄适配。按正文段 sha 比对(B-b 语义,frontmatter 不计入漂移),
|
|
224
263
|
// commands 层按 profiles.mjs#HOSTS.commandPrefix 前缀映射(2026-09-28 起 opencode/trae 同 wf-)。
|
|
@@ -364,6 +403,62 @@ export function checkOwnedDrift(target) {
|
|
|
364
403
|
return { drift, gone, total: owned.length, skipped: false };
|
|
365
404
|
}
|
|
366
405
|
|
|
406
|
+
// ---- 门禁信任档(2026-10-12 gate-trust-level)----
|
|
407
|
+
// 单源:本文件(doctor §6.6b 判定 + init.mjs 写 kit.json 默认值,两处 import 同一份——
|
|
408
|
+
// 装户侧不消费(kit.json 是包源 CLI 写的台账),故不落 templates/_agents/scripts/policy.mjs:
|
|
409
|
+
// 放那边会让装副本带一个它自己不用的判据,两处真值反而更多。两档语义:
|
|
410
|
+
// 'project'(默认):gate 安装锚失配 = **WARN**。装户对门有最终所有权——锚只提供可见性,
|
|
411
|
+
// 有意改动知悉即可。这是 1.7.11 起的现状,本档只是把隐式默认显式命名为「项目自治」。
|
|
412
|
+
// 'org':失配 = **FAIL**(doctor exit 1)。给多仓/组织场景一个真实的强制位——对齐 Claude Code 的
|
|
413
|
+
// managed policy + allowManagedHooksOnly(组织级强制 + 可屏蔽项目自定义 hook)。
|
|
414
|
+
// 为什么**不把默认改成 org**:本仓诚实边界声明写明「门禁关不掉通道」(台账可写、哈希链防不住整文件
|
|
415
|
+
// 重写)。把可见性升成强制而不同步补上「组织侧锚源的不可篡改性」,等于宣称一个关不掉的通道能关——
|
|
416
|
+
// 那比 WARN 更不可信。默认档与现行为逐字一致(零行为变化),要升的人显式改 kit.json 并自行承担
|
|
417
|
+
// 「组织必须同步管住锚来源」的责任。
|
|
418
|
+
// 非法值 / 缺键(旧台账)→ 回退 'project'(= 零行为变化,**绝不因非法配置变严**)。
|
|
419
|
+
export const GATE_TRUST_LEVELS = ['project', 'org'];
|
|
420
|
+
export const GATE_TRUST_DEFAULT = 'project';
|
|
421
|
+
export function gateTrustOf(kit) {
|
|
422
|
+
const v = kit && kit.gateTrust;
|
|
423
|
+
return GATE_TRUST_LEVELS.includes(v) ? v : GATE_TRUST_DEFAULT;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// gate 安装锚校验(独立 export 供 doctor 主流程 + 单元测试共用;2026-10-11 P3-1 gate 防改锚)
|
|
427
|
+
// 判据:kit.owned 中 rel 以 .agents/hooks/ 开头且带 srcSha256 的条目(=add-gate 逐字拷贝安装的 gate 脚本,
|
|
428
|
+
// 锚语义见 src/add-gate.mjs 头注),按 LF 归一算盘面 sha(与 checkOwnedDrift 同口径)比对锚。
|
|
429
|
+
// local-pre-commit 须排除:它是合并接线件,其 srcSha256 由 init/sync 按「模板语义」写(≠ 逐字拷贝锚),
|
|
430
|
+
// 且盘面含各门挂载行——纳入必恒失配误报。
|
|
431
|
+
// 不阻断(默认 'project' 档):装户对门有最终所有权,锚只提供可见性(2026-10-10 调研
|
|
432
|
+
// 「gate 装户可改、hard 项缺防改」结论)。'org' 档(2026-10-12 gate-trust-level,判据单源
|
|
433
|
+
// policy.mjs#gateTrustOf)下失配升 FAIL —— 多仓/组织场景的组织级强制位,对齐 managed policy。
|
|
434
|
+
// 返回:{ total, mismatch, gone, skipped, error?, trust }
|
|
435
|
+
export function checkGateAnchors(target) {
|
|
436
|
+
const kitPath = path.join(target, '.agents', 'kit.json');
|
|
437
|
+
if (!fs.existsSync(kitPath)) return { total: 0, mismatch: [], gone: [], skipped: true };
|
|
438
|
+
let kit;
|
|
439
|
+
try {
|
|
440
|
+
kit = JSON.parse(fs.readFileSync(kitPath, 'utf8'));
|
|
441
|
+
} catch (e) {
|
|
442
|
+
return { total: 0, mismatch: [], gone: [], skipped: true, error: e.message };
|
|
443
|
+
}
|
|
444
|
+
const trust = gateTrustOf(kit); // 缺键/非法 → 'project'(零行为变化)
|
|
445
|
+
const owned = Array.isArray(kit.owned) ? kit.owned : [];
|
|
446
|
+
const anchored = owned.filter((f) => f.rel.startsWith('.agents/hooks/')
|
|
447
|
+
&& f.rel !== '.agents/hooks/local-pre-commit' // 合并接线件,srcSha256 是模板语义锚,非逐字拷贝锚(见上)
|
|
448
|
+
&& typeof f.srcSha256 === 'string' && f.srcSha256);
|
|
449
|
+
if (anchored.length === 0) return { total: 0, mismatch: [], gone: [], skipped: true, trust };
|
|
450
|
+
const mismatch = [];
|
|
451
|
+
const gone = [];
|
|
452
|
+
for (const f of anchored) {
|
|
453
|
+
const p = path.join(target, f.rel);
|
|
454
|
+
if (!fs.existsSync(p)) { gone.push(f.rel); continue; }
|
|
455
|
+
// sha 按 LF 归一口径(同 checkOwnedDrift):CRLF 盘面(autocrlf 检出)与锚不误报
|
|
456
|
+
const h = createHash('sha256').update(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8').digest('hex');
|
|
457
|
+
if (h !== f.srcSha256) mismatch.push(f.rel);
|
|
458
|
+
}
|
|
459
|
+
return { total: anchored.length, mismatch, gone, skipped: false, trust };
|
|
460
|
+
}
|
|
461
|
+
|
|
367
462
|
// 模板下发感知(2026-10-06 template-downstream):只读判据——owned 模板条目三方 sha 比对,
|
|
368
463
|
// 判定单源 profiles.templateDriftOf(与 sync 出账同源,防两处字面量漂移)。恒不写 kit.json。
|
|
369
464
|
// 返回 { skipped, note?, total, stale[] }:skipped=true 时 note 说明原因(无 kit / 无 owned /
|
|
@@ -418,15 +513,8 @@ export function checkAdapterDrift(target) {
|
|
|
418
513
|
return { drift: 0, total: 0, skipped: true, note: '包源环境(templates/ + modules/ 同时存在)——§7.x 仅在装户环境有意义' };
|
|
419
514
|
}
|
|
420
515
|
// 4 宿主目录映射(与顶层 HOST_DIR 同源)
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (!m) return { fm: '', body: String(text || '') };
|
|
424
|
-
return { fm: `---\n${m[1]}---\n`, body: m[2] };
|
|
425
|
-
};
|
|
426
|
-
const bodySha = (p) => {
|
|
427
|
-
// 正文段 LF 归一后哈希(closing-coverage:与 sync-hosts bodySha 同口径,CRLF/LF 盘面不误报漂移)
|
|
428
|
-
try { return createHash('sha256').update(splitFm(fs.readFileSync(p, 'utf8')).body.replace(/\r\n/g, '\n'), 'utf8').digest('hex'); } catch { return null; }
|
|
429
|
-
};
|
|
516
|
+
// splitFm/bodySha 单源复用 sync-hosts(2026-10-11 bodysha-single-source:此前内联第二份实现靠
|
|
517
|
+
// 注释约束同口径,收敛为 import 消灭漂移面)
|
|
430
518
|
let total = 0;
|
|
431
519
|
let drift = 0;
|
|
432
520
|
const listMd = (dir) => fs.existsSync(dir) && fs.statSync(dir).isDirectory() ? fs.readdirSync(dir).filter((f) => f.endsWith('.md')) : [];
|
package/src/gates.test.mjs
CHANGED
|
@@ -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}`);
|
|
@@ -327,6 +327,12 @@ export async function init(args, pkgRoot) {
|
|
|
327
327
|
policyVersion: 2,
|
|
328
328
|
// 新装默认只跑硬规则。缺省(旧台账无此字段)在 check-loop 里仍是全量,避免升级后卫生检查突然消失。
|
|
329
329
|
audit: false,
|
|
330
|
+
// 门禁信任档(2026-10-12 gate-trust-level):默认 'project' —— gate 安装锚失配只 WARN,
|
|
331
|
+
// 装户对门有最终所有权(1.7.11 起现状,此处只是把隐式默认显式命名)。多仓/组织场景改 'org'
|
|
332
|
+
// 即让失配升 FAIL(doctor exit 1),对齐 managed policy 的组织级强制。
|
|
333
|
+
// ⚠️ 默认值取 gateTrustOf / GATE_TRUST_DEFAULT 而非字面量:判据单源在 doctor.mjs(§6.6b 消费),
|
|
334
|
+
// 两处各写一份字符串即漂移面(N3 教训)。
|
|
335
|
+
gateTrust: GATE_TRUST_DEFAULT,
|
|
330
336
|
createdAt: new Date().toISOString(),
|
|
331
337
|
options: { hosts, stack: opt.stack, boardPort: String(opt.boardPort) },
|
|
332
338
|
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/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
|
+
// 引擎基线不是模板:包源径基线的键命名空间与装户盘面不一致(见 check-engine-integrity.mjs
|
|
47
|
+
// resolveLock 注释)——下发会致装户全员误报。包源跑 --update 落盘时排除,杜绝入库即污染
|
|
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) {
|
|
@@ -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);
|