From f7dacac325cba6db3a048ed1c552d1161a6471d6 Mon Sep 17 00:00:00 2001 From: Ace <1160684883@qq.com> Date: Fri, 23 Oct 2026 21:44:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(pipeline):=20violation-precedence=20?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E9=94=AE=E5=9C=A8=20run.at=20=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E6=97=B6=E7=94=A8=E4=BD=8D=E7=BD=AE=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=E5=85=9C=E5=BA=95=EF=BC=88#461=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit violationSeen 跨整本账本共享,而键里的 `run.at ?? ""` 在 at 缺失时塌缩成 空串——所有无时间戳的 run,其同 (step, need) 违规会被并成一条。去重本意 是「同一轮里同一条只报一次」,不是「所有轮共享一条」。 - at 缺失(null/undefined/空串)时改用位置索引兜底;run 没有 id 字段 (normalizeRun 只产出 at/by/triggeredBy/steps),索引是唯一现成判别项 - at 存在时走原值,行为逐字节不变 - 补两条用例:两条无 at 的 run 各报一条;at 存在时同轮重复仍只报一条 现网风险低:写入侧 normalizeRun 与读侧 readStoredRun 都强制 run.at 必填, 合法账本里 at 恒存在。这是防御深度缺口,只在读侧被独立喂入非法形状时触发。 Refs #461 --- dsh-mneme/lib/pipeline.js | 11 +++++++-- dsh-mneme/src/pipeline.js | 11 +++++++-- dsh-mneme/test/pipeline-r1r2.test.js | 34 ++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/dsh-mneme/lib/pipeline.js b/dsh-mneme/lib/pipeline.js index ba2d5cd1..f384c299 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 ba2d5cd1..f384c299 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 a60dc6aa..b827b935 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 的同一违规仍然只报一次"); +});