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 1/2] =?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 的同一违规仍然只报一次"); +}); From b0e204af13be95b488403a88cabea22dcc9c2126 Mon Sep 17 00:00:00 2001 From: Ace <1160684883@qq.com> Date: Sun, 25 Oct 2026 13:36:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(pipeline):=20at=20=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E6=97=B6=E5=85=9C=E5=BA=95=E9=94=AE=E6=94=B9=E7=94=A8=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=8C=87=E7=BA=B9=EF=BC=8C=E5=90=8C=E4=B8=80=20run=20?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=87=BA=E7=8E=B0=E4=B8=8D=E5=86=8D=E6=8A=A5?= =?UTF-8?q?=E4=B8=A4=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #507 评审两点,逐条落实: 1. 兜底键从位置索引改为 run 自身内容(by + triggeredBy + 按序 step id)。 评审指出 violationSeen 跨整本账本共享,而索引只在「本次遍历的数组顺序」 里唯一——同一条 run 在账本里出现两次时,索引会让它的同一条违规被记成 两条,正是本条要修的那类「键塌缩」的反面形态。 评审希望的中间层 run.id 在这里不存在:normalizeRun (src/pipeline-runs.js:187)只产出 { at, by, triggeredBy, steps },而 suggestRetirements 收到的已经是归一化后的 run;补 id 要改写侧契约, 不属这条防御深度问题。内容指纹是同一意图下不动 schema 的等价物。 2. 两类键加不同前缀(at: / anon:)。否则「第 0 条缺 at」与「另一条 at 字面量正好是 #0」会生成同一个键,后一条 violation-precedence 被静默 吞掉(CodeRabbit 同一处也点了这个碰撞)。 测试:补「同一条 run 出现两次只报一次」与「at 字面量与兜底键不相撞」两条, 均已验红→绿(换回索引兜底,这两条转红)。原「两条无 at 的 run 不塌缩」一条 改为用内容不同的两条 run——内容相同的两条在无 id 的前提下不可区分,那是 schema 的事实,不是实现的取舍。 Refs #507 --- dsh-mneme/lib/pipeline.js | 30 ++++++++++++++------- dsh-mneme/src/pipeline.js | 30 ++++++++++++++------- dsh-mneme/test/pipeline-r1r2.test.js | 39 +++++++++++++++++++++++----- 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/dsh-mneme/lib/pipeline.js b/dsh-mneme/lib/pipeline.js index f384c299..643dc6f4 100644 --- a/dsh-mneme/lib/pipeline.js +++ b/dsh-mneme/lib/pipeline.js @@ -116,22 +116,34 @@ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), obse // 在**同一条 run 里**既没执行也没被跳过。查账本级而不是 run 级, // 是因为 tallyRuns 已经把 run 边界抹掉了;这里要保住边界,所以单独扫 runs。 const violationSeen = new Set(); - for (const [i, run] of (runs || []).entries()) { + 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)就不算违规 - // #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}`; + 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 f384c299..643dc6f4 100644 --- a/dsh-mneme/src/pipeline.js +++ b/dsh-mneme/src/pipeline.js @@ -116,22 +116,34 @@ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), obse // 在**同一条 run 里**既没执行也没被跳过。查账本级而不是 run 级, // 是因为 tallyRuns 已经把 run 边界抹掉了;这里要保住边界,所以单独扫 runs。 const violationSeen = new Set(); - for (const [i, run] of (runs || []).entries()) { + 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)就不算违规 - // #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}`; + 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 b827b935..ab87fcfe 100644 --- a/dsh-mneme/test/pipeline-r1r2.test.js +++ b/dsh-mneme/test/pipeline-r1r2.test.js @@ -149,18 +149,17 @@ test("坏输入不抛", () => { }); // #461:violationSeen 跨整本账本共享,而键里的 `run.at ?? ""` 在 at 缺失时塌缩成 -// 空串——于是所有无时间戳的 run,其同 (step, need) 违规会被并成一条。去重本意是 -// 「同一轮里同一条只报一次」,不是「所有轮共享一条」。两条独立的 run 就该产出两条。 -// run 没有 id 字段(normalizeRun 只产出 at/by/triggeredBy/steps),所以兜底用位置索引。 -test("#461:两条无 run.at 的 run 的同类违规不被并成一条", () => { +// 空串——于是**所有**无时间戳的 run,其同 (step, need) 违规会被并成一条。去重本意是 +// 「同一轮里同一条只报一次」,不是「所有轮共享一条」。内容不同的两条 run 该产出两条。 +test("#461:两条内容不同、都无 run.at 的 run,同类违规不被并成一条", () => { const p = makePipeline([ step("A", { tool: "npm test" }), step("B", { tool: "git push", needs: ["A"] }) ]); - // 两条 run 都只做了 B、都没带 at:证据各自独立,该各报一条 + // 都只做了 B、都没带 at,但走的不是同一条路:by 不同 → 内容指纹不同 → 各报一条 const runs = [ { at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] }, - { at: undefined, 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 各报一条,不被键塌缩吞掉"); @@ -168,6 +167,34 @@ test("#461:两条无 run.at 的 run 的同类违规不被并成一条", () => 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([