Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/loopover-engine/src/miner/deny-hook-synthesis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down
28 changes: 28 additions & 0 deletions test/unit/miner-deny-hook-synthesis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)", () => {
Expand Down