diff --git a/src/hooks/grep-direct.ts b/src/hooks/grep-direct.ts index 2c8bb52b..e0ffee31 100644 --- a/src/hooks/grep-direct.ts +++ b/src/hooks/grep-direct.ts @@ -294,9 +294,22 @@ export function parseBashGrep(cmd: string): GrepParams | null { ti++; } - const pattern = explicitPatterns.length > 0 ? explicitPatterns[0] : tokens[ti]; + let pattern = explicitPatterns.length > 0 ? explicitPatterns[0] : tokens[ti]; if (!pattern) return null; + // grep treats repeated -e/--regexp options as alternative patterns. Keep + // them together for the downstream line matcher, which accepts one regex. + // Fixed-string patterns must be escaped before building the alternation. + if (explicitPatterns.length > 1) { + const alternatives = explicitPatterns.map((p) => + fixedString ? p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : p, + ); + // A bare alternation preserves grep's per-pattern OR semantics and keeps + // the alternatives visible to grep-core's SQL prefilter extractor. + pattern = alternatives.join("|"); + fixedString = false; + } + let target = explicitPatterns.length > 0 ? (tokens[ti] ?? "/") : (tokens[ti + 1] ?? "/"); if (target === "." || target === "./") target = "/"; diff --git a/src/shell/grep-core.ts b/src/shell/grep-core.ts index 02709ba9..76098b5e 100644 --- a/src/shell/grep-core.ts +++ b/src/shell/grep-core.ts @@ -614,11 +614,12 @@ export function extractRegexAlternationPrefilters(pattern: string): string[] | n if (escaped || !current) return null; parts.push(current); - const literals = [...new Set( - parts - .map((part) => extractRegexLiteralPrefilter(part)) - .filter((part): part is string => typeof part === "string" && part.length >= 2), - )]; + const branchLiterals = parts.map((part) => extractRegexLiteralPrefilter(part)); + // An OR prefilter must retain at least one safe anchor from every branch. + // Dropping an unanchored branch would turn it into a false-negative filter. + if (branchLiterals.some((literal) => literal === null)) return null; + + const literals = [...new Set(branchLiterals.filter((part): part is string => part !== null))]; return literals.length > 0 ? literals : null; } diff --git a/tests/claude-code/grep-core.test.ts b/tests/claude-code/grep-core.test.ts index 995ad1b5..4ba77cd1 100644 --- a/tests/claude-code/grep-core.test.ts +++ b/tests/claude-code/grep-core.test.ts @@ -1010,6 +1010,10 @@ describe("regex literal prefilter", () => { expect(extractRegexAlternationPrefilters(".|.|.")).toBeNull(); }); + it("returns null when any branch has no safe literal anchor", () => { + expect(extractRegexAlternationPrefilters("apple|\\d+")).toBeNull(); + }); + it("returns null when input has no alternation pipe", () => { expect(extractRegexAlternationPrefilters("foobar")).toBeNull(); }); diff --git a/tests/claude-code/grep-direct.test.ts b/tests/claude-code/grep-direct.test.ts index c4c2fa0b..2b4588a3 100644 --- a/tests/claude-code/grep-direct.test.ts +++ b/tests/claude-code/grep-direct.test.ts @@ -419,6 +419,33 @@ describe("parseBashGrep", () => { }); }); +describe("parseBashGrep: repeated patterns", () => { + it("matches lines for every -e expression", async () => { + const parsed = parseBashGrep("grep -e apple -e banana /notes"); + expect(parsed).not.toBeNull(); + const api = { + query: vi.fn().mockResolvedValueOnce([{ path: "/notes/a.md", content: "apple pie\nbanana bread" }]), + } as any; + const result = await handleGrepDirect(api, "memory", "sessions", parsed!); + expect(result).toBe("apple pie\nbanana bread"); + const sql = api.query.mock.calls[0][0] as string; + expect(sql).toContain("summary::text ILIKE '%apple%'"); + expect(sql).toContain("summary::text ILIKE '%banana%'"); + expect(sql).toContain("message::text ILIKE '%apple%'"); + expect(sql).toContain("message::text ILIKE '%banana%'"); + }); + + it("keeps repeated -F expressions literal", async () => { + const parsed = parseBashGrep("grep -F -e a.b -e banana /notes"); + expect(parsed).not.toBeNull(); + const api = { + query: vi.fn().mockResolvedValueOnce([{ path: "/notes/a.md", content: "a.b\naxb\nbanana" }]), + } as any; + const result = await handleGrepDirect(api, "memory", "sessions", parsed!); + expect(result).toBe("a.b\nbanana"); + }); +}); + // ─── rg (ripgrep) ────────────────────────────────────────────────────────── // // Modern coding agents reach for `rg` by default for directory searches.