@modusensus/dsh-mneme 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +82 -19
  2. package/bin/cli.mjs +69 -9
  3. package/docs/CONFIGURATION.md +48 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +339 -59
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +669 -256
  9. package/lib/config.js +50 -2
  10. package/lib/dream/sleep.js +35 -12
  11. package/lib/dream.js +86 -14
  12. package/lib/embedding.js +153 -22
  13. package/lib/index.js +32 -0
  14. package/lib/inject.js +34 -3
  15. package/lib/lang.js +124 -0
  16. package/lib/pipeline-activities.js +178 -0
  17. package/lib/pipeline-attribution.js +165 -0
  18. package/lib/pipeline-command.js +279 -0
  19. package/lib/pipeline-confirm.js +142 -0
  20. package/lib/pipeline-exec.js +174 -0
  21. package/lib/pipeline-nudge.js +1281 -0
  22. package/lib/pipeline-observations.js +181 -0
  23. package/lib/pipeline-observe.js +133 -0
  24. package/lib/pipeline-runner.js +346 -0
  25. package/lib/pipeline-runs.js +442 -0
  26. package/lib/pipeline.js +128 -7
  27. package/lib/scope-state.js +277 -0
  28. package/lib/semantic.js +66 -10
  29. package/lib/service.js +80 -3
  30. package/lib/settings.js +139 -1
  31. package/lib/store.js +216 -3
  32. package/lib/summarize.js +2 -3
  33. package/lib/tools.js +71 -0
  34. package/package.json +3 -3
  35. package/scripts/changelog-prep.mjs +62 -7
  36. package/scripts/check-sync.js +8 -6
  37. package/scripts/release-docs.mjs +204 -0
  38. package/scripts/release-notes.mjs +162 -0
  39. package/src/api-standalone.js +78 -12
  40. package/src/api.js +339 -59
  41. package/src/candidates.js +454 -454
  42. package/src/config.js +50 -2
  43. package/src/dream/sleep.js +35 -12
  44. package/src/dream.js +86 -14
  45. package/src/embedding.js +153 -22
  46. package/src/index.js +32 -0
  47. package/src/inject.js +34 -3
  48. package/src/lang.js +124 -0
  49. package/src/pipeline-activities.js +178 -0
  50. package/src/pipeline-attribution.js +165 -0
  51. package/src/pipeline-command.js +279 -0
  52. package/src/pipeline-confirm.js +142 -0
  53. package/src/pipeline-exec.js +174 -0
  54. package/src/pipeline-nudge.js +1281 -0
  55. package/src/pipeline-observations.js +181 -0
  56. package/src/pipeline-observe.js +133 -0
  57. package/src/pipeline-runner.js +346 -0
  58. package/src/pipeline-runs.js +442 -0
  59. package/src/pipeline.js +128 -7
  60. package/src/scope-state.js +277 -0
  61. package/src/semantic.js +66 -10
  62. package/src/service.js +80 -3
  63. package/src/settings.js +139 -1
  64. package/src/store.js +216 -3
  65. package/src/summarize.js +2 -3
  66. package/src/tools.js +71 -0
  67. package/test/api-hardening.test.js +173 -0
  68. package/test/api.test.js +210 -1
  69. package/test/changelog-prep.test.js +74 -8
  70. package/test/cli-enums.test.js +37 -0
  71. package/test/cli-flag-value.test.js +86 -0
  72. package/test/client.test.js +216 -6
  73. package/test/distill-tx-embed-gap.test.js +110 -0
  74. package/test/docs-config-keys.test.js +65 -0
  75. package/test/dream-maxtokens-fallback.test.js +132 -0
  76. package/test/embed-text-reasons.test.js +112 -0
  77. package/test/helpers/write-admission-samples.js +6 -2
  78. package/test/lib-smoke.test.js +20 -0
  79. package/test/persona-api.test.js +172 -0
  80. package/test/persona-flag.test.js +36 -0
  81. package/test/persona-inject.test.js +145 -0
  82. package/test/persona-settings.test.js +145 -0
  83. package/test/pipeline-activities.test.js +155 -0
  84. package/test/pipeline-attribution.test.js +175 -0
  85. package/test/pipeline-command.test.js +243 -0
  86. package/test/pipeline-confirm.test.js +148 -0
  87. package/test/pipeline-exec.test.js +172 -0
  88. package/test/pipeline-nudge.test.js +1036 -0
  89. package/test/pipeline-observations.test.js +157 -0
  90. package/test/pipeline-observe.test.js +127 -0
  91. package/test/pipeline-r1r2.test.js +149 -0
  92. package/test/pipeline-runner.test.js +442 -0
  93. package/test/pipeline-runs.test.js +767 -0
  94. package/test/pipeline.test.js +137 -6
  95. package/test/readme-claims.test.js +68 -0
  96. package/test/reindex-backfill.test.js +83 -4
  97. package/test/release-docs.test.js +130 -0
  98. package/test/release-notes.test.js +240 -0
  99. package/test/release-workflow.test.js +16 -0
  100. package/test/route-policy.test.js +113 -0
  101. package/test/scope-state.test.js +481 -0
  102. package/test/security-doc.test.js +139 -0
  103. package/test/sensitive-scan.test.js +44 -0
  104. package/test/tools-dto-schema-matrix.test.js +12 -3
  105. package/test/tools.test.js +6 -6
  106. package/test/workflow-guards.test.js +156 -0
@@ -0,0 +1,157 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, existsSync, readFileSync, appendFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ observationDir, validateObservationLine, saveObservations,
8
+ loadObservations, toolMismatches
9
+ } from "../src/pipeline-observations.js";
10
+
11
+ // P3 核心三件事的锁:
12
+ // ① 观察旁挂(丙):append-only、幂等、🔴 不许命令原文进观察行
13
+ // ② toolMismatches:人确认 done 但声明 tool 没出现 → 提议修表
14
+ // ③ unsure/没答不进判据(那不是「做了但没看到」,是「没确认」)
15
+
16
+ const tmp = mkdtempSync(join(tmpdir(), "mo-p3-"));
17
+ test.after(() => { rmSync(tmp, { recursive: true, force: true }); });
18
+
19
+ const PIPE = {
20
+ id: "demo",
21
+ steps: [
22
+ { id: "P6", title: "跑测试", tool: "npm test" },
23
+ { id: "V2", title: "推分支", tool: "git push" },
24
+ { id: "T4", title: "过闸门", tool: "node scripts/publish-gate.mjs" },
25
+ { id: "V1", title: "人过目", tool: "" } // 纯人判步骤,无从矛盾
26
+ ]
27
+ };
28
+
29
+ test("observationDir:项目键分桶,空键拒绝", () => {
30
+ assert.equal(observationDir({ memoryDir: "D:/mem", projectKey: "proj-a1b2" }), join("D:/mem", "observations", "proj-a1b2"));
31
+ assert.equal(observationDir({ projectKey: "" }), null);
32
+ assert.equal(observationDir({}), null);
33
+ });
34
+
35
+ test("observationDir:~ / ~/ 前缀展开到主目录(config 默认值是波浪线路径,不展开 readdir 永远落空)", () => {
36
+ // candidates.js 的 expandHome 同构先例;不带 ~ 的路径原样通过
37
+ assert.equal(observationDir({ memoryDir: "D:/mem", projectKey: "p" }), join("D:/mem", "observations", "p"));
38
+ const fromTilde = observationDir({ memoryDir: "~/.dsh/memory", projectKey: "p" });
39
+ assert.ok(!fromTilde.startsWith("~"), `tilde must be expanded, got: ${fromTilde}`);
40
+ assert.ok(fromTilde.endsWith(join(".dsh", "memory", "observations", "p")), fromTilde);
41
+ const fromTildeSlash = observationDir({ memoryDir: "~/mem", projectKey: "p" });
42
+ assert.ok(!fromTildeSlash.startsWith("~"), fromTildeSlash);
43
+ });
44
+
45
+ test("🔴 隐私闸:命令原文/参数/分段出现在观察行里一律拒收", () => {
46
+ // 完整命令行含绝对路径(路径含本机用户名),这是第二道闸:上游漏了这里也不放行
47
+ for (const bad of [
48
+ { key: "npm test", count: 1, command: "npm test 2>&1" },
49
+ { key: "npm test", count: 1, args: { a: 1 } },
50
+ { key: "npm test", count: 1, segments: ["npm test"] },
51
+ { key: "", count: 1 },
52
+ { key: "npm test", count: 0 },
53
+ { key: "npm test", count: 1.5 },
54
+ { key: "npm test", count: 1, flags: "not-array" },
55
+ null, "x", 42
56
+ ]) {
57
+ const r = validateObservationLine(bad);
58
+ assert.equal(r.ok, false, JSON.stringify(bad) + " 不该通过");
59
+ }
60
+ // 合法形状(flags 可选)
61
+ assert.equal(validateObservationLine({ key: "npm test", count: 3 }).ok, true);
62
+ assert.equal(validateObservationLine({ key: "git status", count: 1, flags: ["--porcelain"] }).ok, true);
63
+ });
64
+
65
+ test("saveObservations:append-only + 按 confirmAt 幂等", () => {
66
+ const dir = join(tmp, "obs-1");
67
+ const acts = [{ key: "npm test", count: 5 }, { key: "git status", count: 2, flags: ["--porcelain"] }];
68
+ const r1 = saveObservations({ dir, pipelineId: "demo", confirmAt: "2026-10-17T01:00:00.000Z", activities: acts });
69
+ assert.equal(r1.ok, true);
70
+ assert.equal(r1.lines, 2);
71
+ assert.equal(existsSync(r1.file), true);
72
+
73
+ // 同一 confirmAt 再写 → 幂等,不追加
74
+ const r2 = saveObservations({ dir, pipelineId: "demo", confirmAt: "2026-10-17T01:00:00.000Z", activities: acts });
75
+ assert.equal(r2.duplicate, true);
76
+ assert.equal(r2.lines, 0);
77
+ assert.equal(readFileSync(r1.file, "utf8").trim().split("\n").length, 1, "文件只有一行");
78
+
79
+ // 不同 confirmAt → 追加
80
+ saveObservations({ dir, pipelineId: "demo", confirmAt: "2026-10-17T02:00:00.000Z", activities: acts });
81
+ assert.equal(readFileSync(r1.file, "utf8").trim().split("\n").length, 2);
82
+ });
83
+
84
+ test("saveObservations:dryRun 只校验不落盘", () => {
85
+ const dir = join(tmp, "obs-dry");
86
+ const r = saveObservations({ dir, pipelineId: "demo", confirmAt: "2026-10-17T03:00:00.000Z", activities: [{ key: "npm test", count: 1 }], dryRun: true });
87
+ assert.equal(r.ok, true);
88
+ assert.equal(existsSync(r.file), false, "dryRun 不许写文件");
89
+ });
90
+
91
+ test("saveObservations:坏行整体拒绝,不写半截", () => {
92
+ const dir = join(tmp, "obs-bad");
93
+ const r = saveObservations({
94
+ dir, pipelineId: "demo", confirmAt: "2026-10-17T04:00:00.000Z",
95
+ activities: [{ key: "npm test", count: 1 }, { key: "git add src/x.js", count: 1 }]
96
+ });
97
+ // "git add src/x.js" 是合法 key 形状吗?——是。真正被拒的是带原文的:
98
+ // 这里第二条换成显式违规形状再测
99
+ assert.equal(r.ok, true);
100
+
101
+ const r2 = saveObservations({
102
+ dir, pipelineId: "demo", confirmAt: "2026-10-17T05:00:00.000Z",
103
+ activities: [{ key: "npm test", count: 1, command: "npm test" }]
104
+ });
105
+ assert.equal(r2.ok, false, "带命令原文的行必须整体拒");
106
+ assert.match(r2.reason, /forbidden-field/);
107
+ });
108
+
109
+ test("loadObservations:读回 + 坏行计数不静默", () => {
110
+ const dir = join(tmp, "obs-load");
111
+ saveObservations({ dir, pipelineId: "demo", confirmAt: "2026-10-17T06:00:00.000Z", activities: [{ key: "npm test", count: 3 }] });
112
+ // 手工塞一行坏 JSON + 一行形状不对的
113
+ appendFileSync(join(dir, "demo.jsonl"), "{broken\n", "utf8");
114
+ appendFileSync(join(dir, "demo.jsonl"), JSON.stringify({ contract: "wrong/v9", activities: [] }) + "\n", "utf8");
115
+ const { records, unreadable } = loadObservations(dir, "demo");
116
+ assert.equal(records.length, 1);
117
+ assert.equal(unreadable, 2, "坏行要计数,不许静默");
118
+ });
119
+
120
+ test("🔴 toolMismatches:人确认 done 但声明的 tool 没出现 → 提议修表", () => {
121
+ const r = toolMismatches({
122
+ pipeline: PIPE,
123
+ confirmedDone: new Set(["P6", "V1"]),
124
+ activities: [{ key: "node --test", count: 9 }, { key: "git status", count: 4 }]
125
+ });
126
+ assert.equal(r.ok, true);
127
+ assert.equal(r.items.length, 1, "P6 声明 npm test 但没出现 → 唯一不符");
128
+ assert.equal(r.items[0].stepId, "P6");
129
+ assert.equal(r.items[0].declared, "npm test");
130
+ assert.deepEqual(r.items[0].observedTop, ["node --test ×9", "git status ×4"]);
131
+ // V1 无 tool 声明 → 无从矛盾,不该出现
132
+ });
133
+
134
+ test("🔴 多锚点(a / b)任一出现即算对上", () => {
135
+ const pipe = { id: "x", steps: [{ id: "P1", title: "看历史", tool: "git log / gh pr list" }] };
136
+ const hit = toolMismatches({ pipeline: pipe, confirmedDone: new Set(["P1"]), activities: [{ key: "gh pr list", count: 2 }] });
137
+ assert.equal(hit.items.length, 0, "第二个锚点出现了 → 不算不符");
138
+ const miss = toolMismatches({ pipeline: pipe, confirmedDone: new Set(["P1"]), activities: [{ key: "git status", count: 2 }] });
139
+ assert.equal(miss.items.length, 1);
140
+ });
141
+
142
+ test("🔴 unsure 与没答不进判据(没确认 ≠ 做了但没看到)", () => {
143
+ const r = toolMismatches({
144
+ pipeline: PIPE,
145
+ confirmedDone: new Set([]), // 什么都没确认
146
+ activities: [{ key: "git status", count: 1 }]
147
+ });
148
+ assert.equal(r.items.length, 0, "没人说 done,就不该有任何「不符」提议");
149
+ });
150
+
151
+ test("坏输入不抛", () => {
152
+ assert.equal(toolMismatches({ pipeline: null, confirmedDone: new Set(), activities: [] }).reason, "no-pipeline");
153
+ assert.equal(toolMismatches({ pipeline: PIPE, confirmedDone: [], activities: [] }).reason, "bad-confirmed");
154
+ assert.equal(toolMismatches({ pipeline: PIPE, confirmedDone: new Set(), activities: "x" }).reason, "bad-activities");
155
+ assert.equal(saveObservations({}).reason, "no-dir");
156
+ assert.equal(loadObservations(null, "demo").records.length, 0);
157
+ });
@@ -0,0 +1,127 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { commandFromToolCall, outcomeOfToolResult, pairObservations } from "../src/pipeline-observe.js";
4
+
5
+ // 这组测试守的是「会话事件 → 观察」这一层的三条纪律。它们都来自接线前的实测:
6
+ // 真实会话里的工具名是 pwsh/read/glob,而工作流写的是 npm/git/gh 命令行——两者不重叠。
7
+
8
+ test("命令执行器:从 pwsh 的参数里抽出命令行(实测唯一能对上的通路)", () => {
9
+ assert.deepEqual(
10
+ commandFromToolCall("pwsh", '{"command":"npm test","description":"跑测试"}'),
11
+ { tool: "pwsh", command: "npm test" }
12
+ );
13
+ // 对象形状与 JSON 串形状都要认:宿主两种都出现过。
14
+ assert.deepEqual(
15
+ commandFromToolCall("pwsh", { command: "git push origin main" }),
16
+ { tool: "pwsh", command: "git push origin main" }
17
+ );
18
+ assert.equal(commandFromToolCall("bash", '{"cmd":"npm run sync"}').command, "npm run sync");
19
+ });
20
+
21
+ test("非命令执行器一律不给观察:read/glob/grep 是代理日常动作,不是工作流的一步", () => {
22
+ // 硬把 read 的文件路径拼成「命令行」去匹配,只会制造噪声——噪声的代价是凭空染色。
23
+ assert.equal(commandFromToolCall("read", '{"file_path":"C:\\\\x\\\\y.md"}'), null);
24
+ assert.equal(commandFromToolCall("glob", '{"pattern":"**/*.js"}'), null);
25
+ assert.equal(commandFromToolCall("grep", '{"pattern":"foo"}'), null);
26
+ assert.equal(commandFromToolCall("write", '{"content":"hello"}'), null);
27
+ });
28
+
29
+ test("是执行器但抽不出命令行:不猜,返回 null(形状变了也不硬凑)", () => {
30
+ assert.equal(commandFromToolCall("pwsh", '{"description":"忘了给命令"}'), null);
31
+ assert.equal(commandFromToolCall("pwsh", "不是 JSON"), null);
32
+ assert.equal(commandFromToolCall("pwsh", "[]"), null);
33
+ assert.equal(commandFromToolCall("", '{"command":"npm test"}'), null);
34
+ assert.equal(commandFromToolCall("pwsh", '{"command":" "}'), null);
35
+ });
36
+
37
+ test("成败三态:只有布尔才算数,「不知道」不许折叠成「失败」", () => {
38
+ assert.equal(outcomeOfToolResult({ data: { message: { isError: false } } }), true);
39
+ assert.equal(outcomeOfToolResult({ data: { message: { isError: true } } }), false);
40
+ // 拿不到 isError 时留 null——当成 false 会给这一步打上不该有的 refuted 标记。
41
+ assert.equal(outcomeOfToolResult({ data: { message: {} } }), null);
42
+ assert.equal(outcomeOfToolResult({ data: {} }), null);
43
+ assert.equal(outcomeOfToolResult({}), null);
44
+ // 旧形状:isError 挂在 content 的 tool-result 块上。
45
+ assert.equal(outcomeOfToolResult({ data: { message: { content: [{ type: "tool-result", isError: true }] } } }), false);
46
+ assert.equal(outcomeOfToolResult({ data: { message: { content: [{ type: "tool-result", isError: false }] } } }), true);
47
+ });
48
+
49
+ test("配对靠 callId,且只有拿到结果的调用才产观察", () => {
50
+ const events = [
51
+ { type: "tool/call", at: "2026-10-16T12:00:00.000Z", data: { callId: "c1", name: "pwsh", arguments: '{"command":"npm test"}' } },
52
+ { type: "tool/result", data: { callId: "c1", message: { isError: false } } },
53
+ { type: "tool/call", at: "2026-10-16T12:00:05.000Z", data: { callId: "c2", name: "pwsh", arguments: '{"command":"git push"}' } },
54
+ { type: "tool/result", data: { callId: "c2", message: { isError: true } } }
55
+ ];
56
+ const { observations, dangling, notCommands } = pairObservations(events);
57
+ assert.equal(observations.length, 2);
58
+ assert.deepEqual(observations[0], { tool: "pwsh", command: "npm test", ok: true, at: "2026-10-16T12:00:00.000Z" });
59
+ assert.equal(observations[1].ok, false, "失败要如实带出来");
60
+ assert.equal(dangling.length, 0);
61
+ assert.equal(notCommands, 0);
62
+ });
63
+
64
+ test("调了但没结果:进 dangling,既不产观察也不假装失败", () => {
65
+ // 「中断了」既不是成功也不是失败;拿它当一次成功使用是编造。
66
+ const { observations, dangling } = pairObservations([
67
+ { type: "tool/call", data: { callId: "c9", name: "pwsh", arguments: '{"command":"npm publish"}' } }
68
+ ]);
69
+ assert.equal(observations.length, 0);
70
+ assert.equal(dangling.length, 1);
71
+ assert.match(dangling[0].why, /没有等到结果/);
72
+ });
73
+
74
+ test("没有 callId 的调用不硬凑配对,如实进 dangling", () => {
75
+ const { observations, dangling } = pairObservations([
76
+ { type: "tool/call", data: { name: "pwsh", arguments: '{"command":"npm test"}' } }
77
+ ]);
78
+ assert.equal(observations.length, 0);
79
+ assert.equal(dangling.length, 1);
80
+ assert.match(dangling[0].why, /没有 callId/);
81
+ });
82
+
83
+ test("非命令工具的调用计入 notCommands,不产观察也不出错", () => {
84
+ const { observations, notCommands } = pairObservations([
85
+ { type: "tool/call", data: { callId: "r1", name: "read", arguments: '{"file_path":"x.md"}' } },
86
+ { type: "tool/result", data: { callId: "r1", message: { isError: false } } },
87
+ { type: "tool/call", data: { callId: "p1", name: "pwsh", arguments: '{"command":"npm test"}' } },
88
+ { type: "tool/result", data: { callId: "p1", message: { isError: false } } }
89
+ ]);
90
+ assert.equal(notCommands, 1);
91
+ assert.equal(observations.length, 1);
92
+ assert.equal(observations[0].command, "npm test");
93
+ });
94
+
95
+ test("坏输入不抛:空、非数组、缺字段一律返回空结果", () => {
96
+ for (const bad of [null, undefined, [], [{}], [{ type: "tool/call" }], [{ type: "tool/result" }], [0, "x"]]) {
97
+ const r = pairObservations(bad);
98
+ assert.equal(Array.isArray(r.observations), true);
99
+ assert.equal(Array.isArray(r.dangling), true);
100
+ assert.equal(typeof r.notCommands, "number");
101
+ }
102
+ });
103
+
104
+ test("观察能直接喂给执行窗口:两个模块的契约对得上", async () => {
105
+ const { openRun } = await import("../src/pipeline-exec.js");
106
+ const { validatePipeline } = await import("../src/pipeline.js");
107
+ const p = validatePipeline({
108
+ id: "demo", name: "演示", project: "a/b", version: 0, status: "active", done: "走完",
109
+ trigger: [{ kind: "phrase", value: "走一遍" }],
110
+ steps: [{
111
+ id: "P6", phase: "准备", title: "跑测试", when: "改完", do: "npm test",
112
+ who: "main", tool: "npm test", done: "全绿", risk: "R0", needs: [], why: "因为"
113
+ }]
114
+ }, "demo.json");
115
+
116
+ const { observations } = pairObservations([
117
+ { type: "tool/call", at: "2026-10-16T12:00:00.000Z", data: { callId: "c1", name: "pwsh", arguments: '{"command":"npm test"}' } },
118
+ { type: "tool/result", data: { callId: "c1", message: { isError: false } } }
119
+ ]);
120
+
121
+ const run = openRun({ pipeline: p, now: () => new Date("2026-10-16T12:00:10.000Z") });
122
+ for (const obs of observations) run.observe(obs);
123
+ const closed = run.close();
124
+
125
+ assert.deepEqual(closed.run.steps.map((s) => s.id), ["P6"], "实测链路:事件 → 观察 → 归属 → 一步进账");
126
+ assert.equal(closed.run.steps[0].refuted, false);
127
+ });
@@ -0,0 +1,149 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validatePipeline, suggestRetirements } from "../src/pipeline.js";
4
+
5
+ // R1/R2 + P3 投影的判据锁。
6
+ // 核心不变量:**四类提议互不吞并**(07 号文的教训——第一版 if/else if,
7
+ // 绕行把跳过吃掉了)。每类有自己的触发条件,可以并存,谁也不许吃掉谁。
8
+
9
+ function makePipeline(steps) {
10
+ return validatePipeline({
11
+ id: "demo", name: "演示", project: "a/b", version: 0, status: "active", done: "走完",
12
+ trigger: [{ kind: "phrase", value: "走一遍" }],
13
+ steps
14
+ }, "demo.json");
15
+ }
16
+
17
+ const BASE_STEP = {
18
+ phase: "p", when: "w", do: "d", who: "main", done: "dn", risk: "R0", why: "y"
19
+ };
20
+ const step = (id, extra = {}) => ({ id, title: id, tool: "", needs: [], ...BASE_STEP, ...extra });
21
+
22
+ const RUN = (at, steps) => ({ at, by: "human", triggeredBy: "demo", steps });
23
+
24
+ test("R2:optional 步骤被跳 3 次 → optional-skip-ok 信息项,绝不提议删", () => {
25
+ const p = makePipeline([step("OPT", { optional: true, tool: "npm run lint" })]);
26
+ const runs = [1, 2, 3].map((i) => RUN(`2026-10-0${i}T00:00:00Z`, [{ id: "OPT", at: "x", skipped: true }]));
27
+ const out = suggestRetirements(p, { runs });
28
+ const kinds = out.map((s) => s.kind);
29
+ assert.equal(kinds.includes("drop-step"), false, "可选步骤绝不能被提议删");
30
+ assert.equal(kinds.includes("optional-skip-ok"), true, "给一条信息项供人留意");
31
+ });
32
+
33
+ test("R2:必经步骤被跳 3 次 → drop-step 与 rework-path 并存(两种读法都给人)", () => {
34
+ const p = makePipeline([step("REQ", { tool: "npm test" })]);
35
+ const runs = [1, 2, 3].map((i) => RUN(`2026-10-0${i}T00:00:00Z`, [{ id: "REQ", at: "x", skipped: true }]));
36
+ const out = suggestRetirements(p, { runs });
37
+ // 必经的一步总被跳:既可能是「这步多余」(删),也可能是「流程写错」(修图)。
38
+ // 两种读法同时给——07 号文教训的反面:不许 if/else 吞掉任何一种。
39
+ assert.equal(out.some((s) => s.kind === "rework-path" && s.because.includes("必经")), true);
40
+ assert.equal(out.some((s) => s.kind === "drop-step"), true, "从未 uses 的必经步骤,删步候选也在");
41
+ });
42
+
43
+ test("drop-step 只给「必经且从未 uses 且被跳够数」的步骤", () => {
44
+ // never used:账本里只有 skipped 行、无 uses
45
+ const p = makePipeline([step("GHOST", { tool: "gh pr merge --rebase" })]);
46
+ const runs = [1, 2, 3].map((i) => RUN(`2026-10-0${i}T00:00:00Z`, [{ id: "GHOST", at: "x", skipped: true }]));
47
+ const out = suggestRetirements(p, { runs });
48
+ // 与 rework-path 并存(必经被跳也成立),但 drop-step 必须在
49
+ assert.equal(out.some((s) => s.kind === "drop-step" && s.stepId === "GHOST"), true, "写进清单但从不执行 → 才是删步候选");
50
+ // 而 optional 同形状不给 drop-step
51
+ const p2 = makePipeline([step("G2", { optional: true, tool: "x" })]);
52
+ const out2 = suggestRetirements(p2, { runs });
53
+ assert.equal(out2.some((s) => s.kind === "drop-step"), false);
54
+ });
55
+
56
+ test("R1:前置违规一次就报——done 了后继但 needs 前置整条缺失", () => {
57
+ const p = makePipeline([
58
+ step("A", { tool: "npm test" }),
59
+ step("B", { tool: "git push", needs: ["A"] })
60
+ ]);
61
+ // 只做了 B,A 连行都没有 → 违规
62
+ const runs = [RUN("2026-10-17T00:00:00Z", [{ id: "B", at: "x" }])];
63
+ const out = suggestRetirements(p, { runs });
64
+ const v = out.filter((s) => s.kind === "violation-precedence");
65
+ assert.equal(v.length, 1, "一次就报,不攒次数");
66
+ assert.equal(v[0].stepId, "B");
67
+ assert.match(v[0].because, /A/);
68
+ });
69
+
70
+ test("R1:前置做过(哪怕 skipped)不算违规;多前置断在缺的那条边上", () => {
71
+ const p = makePipeline([
72
+ step("A"), step("C"),
73
+ step("B", { needs: ["A", "C"] })
74
+ ]);
75
+ // A 做了、C 被跳过 → 前置都「有着落」,不违规
76
+ const ok = suggestRetirements(p, { runs: [RUN("t1", [{ id: "A", at: "x" }, { id: "C", at: "x", skipped: true }, { id: "B", at: "x" }])] });
77
+ assert.equal(ok.filter((s) => s.kind === "violation-precedence").length, 0);
78
+ // A 做了、C 整条没有 → 恰好一条,点名 C
79
+ const bad = suggestRetirements(p, { runs: [RUN("t2", [{ id: "A", at: "x" }, { id: "B", at: "x" }])] });
80
+ const v = bad.filter((s) => s.kind === "violation-precedence");
81
+ assert.equal(v.length, 1);
82
+ assert.match(v[0].because, /C/);
83
+ });
84
+
85
+ test("🔴 四类互不吞并:同一 run 里能并存的都出现", () => {
86
+ const p = makePipeline([
87
+ step("OPT", { optional: true }),
88
+ step("GHOST", { tool: "x" }),
89
+ step("A", { tool: "npm test" }),
90
+ step("B", { tool: "git push", needs: ["A"] })
91
+ ]);
92
+ const runs = [
93
+ // OPT 被跳(optional-skip-ok);GHOST 被跳(drop-step 候选);
94
+ // B done 而 A 缺(violation-precedence)
95
+ RUN("t1", [{ id: "OPT", at: "x", skipped: true }, { id: "GHOST", at: "x", skipped: true }, { id: "B", at: "x" }]),
96
+ RUN("t2", [{ id: "OPT", at: "x", skipped: true }, { id: "GHOST", at: "x", skipped: true }, { id: "B", at: "x" }]),
97
+ RUN("t3", [{ id: "OPT", at: "x", skipped: true }, { id: "GHOST", at: "x", skipped: true }, { id: "B", at: "x" }])
98
+ ];
99
+ const kinds = suggestRetirements(p, { runs }).map((s) => s.kind);
100
+ for (const k of ["optional-skip-ok", "drop-step", "violation-precedence", "rework-path"]) {
101
+ assert.equal(kinds.includes(k), true, `缺 ${k}`);
102
+ }
103
+ });
104
+
105
+ test("P3 投影:声明 tool 从未出现在观察里 + 被确认做过 → fix-step-tool", () => {
106
+ const p = makePipeline([
107
+ step("P6", { tool: "npm test" }),
108
+ step("V2", { tool: "git push", needs: ["P6"] })
109
+ ]);
110
+ const runs = [RUN("t1", [{ id: "P6", at: "x" }])]; // 人确认 P6 做了
111
+ const observed = { latest: { activities: [{ key: "node --test", count: 9 }, { key: "git status", count: 4 }] } };
112
+ const out = suggestRetirements(p, { runs, observed });
113
+ const f = out.filter((s) => s.kind === "fix-step-tool");
114
+ assert.equal(f.length, 1, "P6 声明 npm test 未出现 → 一条");
115
+ assert.equal(f[0].stepId, "P6");
116
+ assert.deepEqual(f[0].observedTop, ["node --test ×9", "git status ×4"]);
117
+ });
118
+
119
+ test("P3 投影:多锚点任一命中即对上;optional/无 tool/未确认过 全部不进判据", () => {
120
+ const p = makePipeline([
121
+ step("P1", { tool: "git log / gh pr list" }),
122
+ step("V1", { tool: "" }),
123
+ step("OPT", { optional: true, tool: "npm run lint" })
124
+ ]);
125
+ const runs = [RUN("t1", [{ id: "P1", at: "x" }, { id: "V1", at: "x" }, { id: "OPT", at: "x" }])];
126
+ // 观察里有 gh pr list → P1 对上;观察为空 → V1/OPT 也不该报(V1 无声明、OPT 可选)
127
+ const out = suggestRetirements(p, { runs, observed: { latest: { activities: [{ key: "gh pr list", count: 2 }] } } });
128
+ assert.equal(out.filter((s) => s.kind === "fix-step-tool").length, 0);
129
+ // 换成啥都没有的观察:P1 的两个锚点都没出现 → 报
130
+ const out2 = suggestRetirements(p, { runs, observed: { latest: { activities: [{ key: "docker ps", count: 1 }] } } });
131
+ const f = out2.filter((s) => s.kind === "fix-step-tool");
132
+ assert.equal(f.length, 1);
133
+ assert.equal(f[0].stepId, "P1");
134
+ });
135
+
136
+ test("P3 投影:没传 observed 时行为与旧版完全一致(零回归)", () => {
137
+ const p = makePipeline([step("A", { tool: "npm test" }), step("B", { needs: ["A"] })]);
138
+ const runs = [RUN("t1", [{ id: "OPT0", at: "x", skipped: true }])];
139
+ const a = suggestRetirements(p, { runs });
140
+ const b = suggestRetirements(p, { runs, observed: null });
141
+ assert.deepEqual(a, b);
142
+ });
143
+
144
+ test("坏输入不抛", () => {
145
+ const p = makePipeline([step("A")]);
146
+ assert.doesNotThrow(() => suggestRetirements(p, { runs: null }));
147
+ assert.doesNotThrow(() => suggestRetirements(p, { runs: [{}] }));
148
+ assert.doesNotThrow(() => suggestRetirements(p, { runs: [{ at: null, steps: null }] }));
149
+ });