diff --git a/dsh-mneme/lib/pipeline.js b/dsh-mneme/lib/pipeline.js index ba2d5cd..f384c29 100644 --- a/dsh-mneme/lib/pipeline.js +++ b/dsh-mneme/lib/pipeline.js @@ -116,7 +116,7 @@ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), obse // 在**同一条 run 里**既没执行也没被跳过。查账本级而不是 run 级, // 是因为 tallyRuns 已经把 run 边界抹掉了;这里要保住边界,所以单独扫 runs。 const violationSeen = new Set(); - for (const run of runs || []) { + for (const [i, run] of (runs || []).entries()) { const doneIds = new Set((run.steps || []).filter((s) => s && !s.skipped && !s.detoured).map((s) => s.id)); for (const step of pipeline.steps) { if (!doneIds.has(step.id) || !Array.isArray(step.needs)) continue; @@ -124,7 +124,14 @@ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), obse 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}`; + // #461:at 缺失(null/undefined/空串)时用位置索引兜底,不用空串。 + // violationSeen 跨整本账本共享,空串会让所有无时间戳的 run 的同 + // (step, need) 违规塌缩成一条——去重本意是「同一轮里同一条只报一次」, + // 不是「所有无时间戳的轮次共享一条」。run 没有 id 字段(normalizeRun + // 只产出 at/by/triggeredBy/steps),索引是这里唯一现成的判别项。 + // at 存在时走原值,行为逐字节不变。 + const at = isNonEmptyString(run.at) ? run.at : `#${i}`; + const vk = `${at}|${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..f384c29 100644 --- a/dsh-mneme/src/pipeline.js +++ b/dsh-mneme/src/pipeline.js @@ -116,7 +116,7 @@ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), obse // 在**同一条 run 里**既没执行也没被跳过。查账本级而不是 run 级, // 是因为 tallyRuns 已经把 run 边界抹掉了;这里要保住边界,所以单独扫 runs。 const violationSeen = new Set(); - for (const run of runs || []) { + for (const [i, run] of (runs || []).entries()) { const doneIds = new Set((run.steps || []).filter((s) => s && !s.skipped && !s.detoured).map((s) => s.id)); for (const step of pipeline.steps) { if (!doneIds.has(step.id) || !Array.isArray(step.needs)) continue; @@ -124,7 +124,14 @@ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), obse 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}`; + // #461:at 缺失(null/undefined/空串)时用位置索引兜底,不用空串。 + // violationSeen 跨整本账本共享,空串会让所有无时间戳的 run 的同 + // (step, need) 违规塌缩成一条——去重本意是「同一轮里同一条只报一次」, + // 不是「所有无时间戳的轮次共享一条」。run 没有 id 字段(normalizeRun + // 只产出 at/by/triggeredBy/steps),索引是这里唯一现成的判别项。 + // at 存在时走原值,行为逐字节不变。 + const at = isNonEmptyString(run.at) ? run.at : `#${i}`; + const vk = `${at}|${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..b827b93 100644 --- a/dsh-mneme/test/pipeline-r1r2.test.js +++ b/dsh-mneme/test/pipeline-r1r2.test.js @@ -147,3 +147,37 @@ test("坏输入不抛", () => { assert.doesNotThrow(() => suggestRetirements(p, { runs: [{}] })); assert.doesNotThrow(() => suggestRetirements(p, { runs: [{ at: null, steps: null }] })); }); + +// #461:violationSeen 跨整本账本共享,而键里的 `run.at ?? ""` 在 at 缺失时塌缩成 +// 空串——于是所有无时间戳的 run,其同 (step, need) 违规会被并成一条。去重本意是 +// 「同一轮里同一条只报一次」,不是「所有轮共享一条」。两条独立的 run 就该产出两条。 +// run 没有 id 字段(normalizeRun 只产出 at/by/triggeredBy/steps),所以兜底用位置索引。 +test("#461:两条无 run.at 的 run 的同类违规不被并成一条", () => { + const p = makePipeline([ + step("A", { tool: "npm test" }), + step("B", { tool: "git push", needs: ["A"] }) + ]); + // 两条 run 都只做了 B、都没带 at:证据各自独立,该各报一条 + const runs = [ + { at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] }, + { at: undefined, 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, "两条独立的 run 各报一条,不被键塌缩吞掉"); + assert.equal(v[0].stepId, "B"); + assert.equal(v[1].stepId, "B"); +}); + +// 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 的同一违规仍然只报一次"); +});