diff --git a/dsh-mneme/lib/pipeline.js b/dsh-mneme/lib/pipeline.js index ba2d5cd..643dc6f 100644 --- a/dsh-mneme/lib/pipeline.js +++ b/dsh-mneme/lib/pipeline.js @@ -118,13 +118,32 @@ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), obse const violationSeen = new Set(); for (const run of runs || []) { const doneIds = new Set((run.steps || []).filter((s) => s && !s.skipped && !s.detoured).map((s) => s.id)); + // #461:一条 run 的判别键分两类,两类永不相撞。 + // + // at 存在(合法账本的唯一形态——normalizeRun 在写侧强制必填)走 `at:<值>`。 + // at 缺失(读侧被独立喂进非法形状,或未来写契约放宽)走 `anon:<内容指纹>`: + // by + triggeredBy + 按序的 step id。用**内容**而不是数组索引,是因为 + // violationSeen 跨整本账本共享,而索引只在「本次遍历的数组顺序」里唯一—— + // 同一条 run 在账本里出现两次时,索引会让它的同一条违规被记成两条, + // 那正是本条要修的那类「键塌缩」的反面形态(#507 评审)。 + // + // 评审里希望中间加一层 run.id,但 normalizeRun(src/pipeline-runs.js:187) + // 只产出 { at, by, triggeredBy, steps },没有 id 字段,而 suggestRetirements + // 收到的已经是归一化后的 run;补 id 要改写侧契约,不属这条防御深度问题。 + // 内容指纹是同一意图下不需要动 schema 的等价物。 + // + // 两类键必须带不同前缀:否则「第 0 条缺 at」与「另一条 at 字面量正好是 + // #0」会生成同一个键,后一条 violation-precedence 被静默吞掉。 + const runKey = isNonEmptyString(run.at) + ? `at:${run.at}` + : `anon:${run.by ?? ""}|${run.triggeredBy ?? ""}|${(run.steps || []).map((s) => s?.id ?? "").join(",")}`; for (const step of pipeline.steps) { if (!doneIds.has(step.id) || !Array.isArray(step.needs)) continue; for (const need of step.needs) { if (doneIds.has(need)) continue; const had = (run.steps || []).some((s) => s && s.id === need); if (had) continue; // 前置做过(哪怕是 skipped)就不算违规 - const vk = `${run.at ?? ""}|${step.id}|${need}`; + const vk = `${runKey}|${step.id}|${need}`; if (violationSeen.has(vk)) continue; violationSeen.add(vk); // 同一步对不同前置各报一条:修图的人需要知道**断在哪条边**上 diff --git a/dsh-mneme/src/pipeline.js b/dsh-mneme/src/pipeline.js index ba2d5cd..643dc6f 100644 --- a/dsh-mneme/src/pipeline.js +++ b/dsh-mneme/src/pipeline.js @@ -118,13 +118,32 @@ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), obse const violationSeen = new Set(); for (const run of runs || []) { const doneIds = new Set((run.steps || []).filter((s) => s && !s.skipped && !s.detoured).map((s) => s.id)); + // #461:一条 run 的判别键分两类,两类永不相撞。 + // + // at 存在(合法账本的唯一形态——normalizeRun 在写侧强制必填)走 `at:<值>`。 + // at 缺失(读侧被独立喂进非法形状,或未来写契约放宽)走 `anon:<内容指纹>`: + // by + triggeredBy + 按序的 step id。用**内容**而不是数组索引,是因为 + // violationSeen 跨整本账本共享,而索引只在「本次遍历的数组顺序」里唯一—— + // 同一条 run 在账本里出现两次时,索引会让它的同一条违规被记成两条, + // 那正是本条要修的那类「键塌缩」的反面形态(#507 评审)。 + // + // 评审里希望中间加一层 run.id,但 normalizeRun(src/pipeline-runs.js:187) + // 只产出 { at, by, triggeredBy, steps },没有 id 字段,而 suggestRetirements + // 收到的已经是归一化后的 run;补 id 要改写侧契约,不属这条防御深度问题。 + // 内容指纹是同一意图下不需要动 schema 的等价物。 + // + // 两类键必须带不同前缀:否则「第 0 条缺 at」与「另一条 at 字面量正好是 + // #0」会生成同一个键,后一条 violation-precedence 被静默吞掉。 + const runKey = isNonEmptyString(run.at) + ? `at:${run.at}` + : `anon:${run.by ?? ""}|${run.triggeredBy ?? ""}|${(run.steps || []).map((s) => s?.id ?? "").join(",")}`; for (const step of pipeline.steps) { if (!doneIds.has(step.id) || !Array.isArray(step.needs)) continue; for (const need of step.needs) { if (doneIds.has(need)) continue; const had = (run.steps || []).some((s) => s && s.id === need); if (had) continue; // 前置做过(哪怕是 skipped)就不算违规 - const vk = `${run.at ?? ""}|${step.id}|${need}`; + const vk = `${runKey}|${step.id}|${need}`; if (violationSeen.has(vk)) continue; violationSeen.add(vk); // 同一步对不同前置各报一条:修图的人需要知道**断在哪条边**上 diff --git a/dsh-mneme/test/pipeline-r1r2.test.js b/dsh-mneme/test/pipeline-r1r2.test.js index a60dc6a..ab87fcf 100644 --- a/dsh-mneme/test/pipeline-r1r2.test.js +++ b/dsh-mneme/test/pipeline-r1r2.test.js @@ -147,3 +147,64 @@ test("坏输入不抛", () => { assert.doesNotThrow(() => suggestRetirements(p, { runs: [{}] })); assert.doesNotThrow(() => suggestRetirements(p, { runs: [{ at: null, steps: null }] })); }); + +// #461:violationSeen 跨整本账本共享,而键里的 `run.at ?? ""` 在 at 缺失时塌缩成 +// 空串——于是**所有**无时间戳的 run,其同 (step, need) 违规会被并成一条。去重本意是 +// 「同一轮里同一条只报一次」,不是「所有轮共享一条」。内容不同的两条 run 该产出两条。 +test("#461:两条内容不同、都无 run.at 的 run,同类违规不被并成一条", () => { + const p = makePipeline([ + step("A", { tool: "npm test" }), + step("B", { tool: "git push", needs: ["A"] }) + ]); + // 都只做了 B、都没带 at,但走的不是同一条路:by 不同 → 内容指纹不同 → 各报一条 + const runs = [ + { at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] }, + { at: undefined, by: "agent", triggeredBy: "nightly", steps: [{ id: "B", at: "x" }] } + ]; + const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence"); + assert.equal(v.length, 2, "两条独立的 run 各报一条,不被键塌缩吞掉"); + assert.equal(v[0].stepId, "B"); + assert.equal(v[1].stepId, "B"); +}); + +// #507 评审:同一个 run 在账本里出现两次,同一条违规仍只报一次。 +// 位置索引做不到这件事——它只在「本次遍历的数组顺序」里唯一,同一条 run 出现在 +// 下标 0 和 1 时会拿到两个键。所以 at 缺失时兜底用内容指纹,不用索引。 +test("#507:同一条 run 出现两次,同类违规仍只报一次", () => { + const p = makePipeline([ + step("A", { tool: "npm test" }), + step("B", { tool: "git push", needs: ["A"] }) + ]); + const same = { at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] }; + const v = suggestRetirements(p, { runs: [same, same] }).filter((s) => s.kind === "violation-precedence"); + assert.equal(v.length, 1, "同一条 run 的两份记录是同一个观测,不是两次违规"); +}); + +// #507 评审(CodeRabbit):两类键必须不相撞。第 0 条缺 at,而另一条的 at 字面量 +// 正好是 "#0"——用位置索引兜底时两者生成同一个键,后一条违规被静默吞掉。 +test("#507:at 字面量与兜底键不相撞(第 0 条缺 at vs 另一条 at 是 \"#0\")", () => { + const p = makePipeline([ + step("A", { tool: "npm test" }), + step("B", { tool: "git push", needs: ["A"] }) + ]); + const runs = [ + { at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] }, + { at: "#0", by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] } + ]; + const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence"); + assert.equal(v.length, 2, "字面量 #0 与兜底键不是同一个东西,两条都要报"); +}); + +// at 存在时行为不变:同 at 的重复违规仍然只报一条(原有去重语义不能被修坏)。 +test("#461:at 存在时同轮重复违规仍只报一条(回归保护)", () => { + const p = makePipeline([ + step("A", { tool: "npm test" }), + step("B", { tool: "git push", needs: ["A"] }) + ]); + const runs = [ + RUN("2026-10-17T00:00:00Z", [{ id: "B", at: "x" }]), + RUN("2026-10-17T00:00:00Z", [{ id: "B", at: "x" }]) + ]; + const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence"); + assert.equal(v.length, 1, "同一 at 的同一违规仍然只报一次"); +});