diff --git a/packages/loopover-engine/src/miner/deny-hook-synthesis.ts b/packages/loopover-engine/src/miner/deny-hook-synthesis.ts index 7b811d0cf4..1a7f206aca 100644 --- a/packages/loopover-engine/src/miner/deny-hook-synthesis.ts +++ b/packages/loopover-engine/src/miner/deny-hook-synthesis.ts @@ -130,10 +130,15 @@ export function changedPathToDenyGlob(path: string): string | null { } function ruleSignature(rule: DenyRule): string { + // `inputTokenPattern` is a RegExp: JSON.stringify serializes every RegExp instance as "{}" regardless of its + // actual source/flags (no enumerable own properties, no toJSON), so two rules with genuinely different + // patterns would still collide here if the RegExp object itself were included directly. Serialize `.source` + // + `.flags` instead so distinct patterns produce distinct signatures. return JSON.stringify({ matcher: rule.matcher, pathPattern: rule.pathPattern ?? null, inputIncludesAll: rule.inputIncludesAll ?? null, + inputTokenPattern: rule.inputTokenPattern ? { source: rule.inputTokenPattern.source, flags: rule.inputTokenPattern.flags } : null, reason: rule.reason, }); } diff --git a/test/unit/miner-deny-hook-synthesis.test.ts b/test/unit/miner-deny-hook-synthesis.test.ts index 8c47978af4..9da4426fd7 100644 --- a/test/unit/miner-deny-hook-synthesis.test.ts +++ b/test/unit/miner-deny-hook-synthesis.test.ts @@ -17,6 +17,7 @@ import { setProposalStatuses, synthesizeDenyRuleProposals, } from "../../packages/loopover-miner/lib/deny-hook-synthesis.js"; +import type { DenyRuleProposal } from "../../packages/loopover-engine/src/miner/deny-hook-synthesis"; // #7525: normalizeRepoFullName is defined in the engine and re-exported unchanged by the miner-lib module // above; import it from the engine source directly so the guard's src branches are the ones exercised. import { normalizeRepoFullName } from "../../packages/loopover-engine/src/miner/deny-hook-synthesis"; @@ -128,6 +129,33 @@ describe("resolveEffectiveDenyRules() (#4522)", () => { expect(verdict.allowed).toBe(false); expect(verdict.blockedBy?.pathPattern).toBe("**/changelog.md"); }); + + it("#8013: keeps two rules distinct when they differ only by inputTokenPattern, including two different patterns (not just presence vs. absence)", () => { + const proposal = (id: string, inputTokenPattern?: RegExp): DenyRuleProposal => ({ + id, + status: "approved", + rule: { matcher: "Bash", inputIncludesAll: ["git"], reason: "test", ...(inputTokenPattern ? { inputTokenPattern } : {}) }, + audit: { kind: "manual", synthesizedAt: "2026-01-01T00:00:00.000Z" }, + }); + const noPattern = proposal("a"); + const withFollowTags = proposal("b", /^--follow-tags$/); + const withF = proposal("c", /^-f$/); + + const effective = resolveEffectiveDenyRules({ includeDefaults: false, approvedProposals: [noPattern, withFollowTags, withF] }); + // All three must survive -- none of them are "the same rule" as either of the others. + expect(effective).toHaveLength(3); + }); + + it("#8013: still dedupes two rules whose inputTokenPattern has the identical source+flags", () => { + const proposal = (id: string): DenyRuleProposal => ({ + id, + status: "approved", + rule: { matcher: "Bash", inputIncludesAll: ["git"], reason: "test", inputTokenPattern: /^-f$/ }, + audit: { kind: "manual", synthesizedAt: "2026-01-01T00:00:00.000Z" }, + }); + const effective = resolveEffectiveDenyRules({ includeDefaults: false, approvedProposals: [proposal("a"), proposal("b")] }); + expect(effective).toHaveLength(1); + }); }); describe("initDenyHookSynthesisStore() (#4522)", () => {