From e195a47205a9cfd6437911aac83eb2f9ea4b9c24 Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sun, 17 May 2026 10:59:41 -0400 Subject: [PATCH 1/2] fix(glob,grep): tolerate broken symlinks and non-fatal I/O warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3726. ## Root cause Two coupled bugs in the ripgrep integration caused the glob and grep tools to fail outright when the search path contained a broken (dangling) symlink: 1. **Missing `--no-messages`** in `RG_FILES_FLAGS` (glob) and `RG_SAFETY_FLAGS` (grep). ripgrep prints a stderr warning for every broken symlink: `rg: /path/to/broken-link: No such file or directory (os error 2)` 2. **Overly strict exit-code gate** in both `glob/cli.ts` and `grep/cli.ts`: `if (exitCode > 1 && stderr.trim())` treated exit code 2 as fatal and discarded the stdout, even though ripgrep exits 2 on *non-fatal* I/O issues (broken symlinks, permission denied) while still printing valid results to stdout. Combined effect: a single broken symlink anywhere in the search path turned all subsequent file matches into an empty result with an error. ## Fix - Append `--no-messages` to `RG_FILES_FLAGS` (`src/tools/glob/constants.ts`) and to `RG_SAFETY_FLAGS` (`src/tools/grep/constants.ts`). This silences ripgrep's non-fatal stderr warnings without changing match behavior. - Relax the exit-code gate in both `glob/cli.ts` and `grep/cli.ts` from `> 1` to `> 2`, matching ripgrep's documented contract: - 0 = matches found - 1 = no matches (success, just nothing matched) - 2 = non-fatal I/O issues (partial success — stdout is still valid) - >2 = fatal error ## Test changes - New regression assertion in `src/tools/glob/cli.test.ts` for `buildRgArgs` confirms `--no-messages` is included in the args. - The grep change is symmetric (same flag, same rationale, same exit-code constant) and rides on the parallel structure. ## Verification - `bun test src/tools/glob/` → all pass (19 tests in cli.test.ts) - `bun test src/tools/grep/` → all pre-existing tests still pass - `bunx tsc --noEmit` → clean Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/glob/cli.test.ts | 9 +++++++++ src/tools/glob/cli.ts | 5 ++++- src/tools/glob/constants.ts | 4 ++++ src/tools/grep/cli.ts | 5 ++++- src/tools/grep/constants.ts | 4 ++++ 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/tools/glob/cli.test.ts b/src/tools/glob/cli.test.ts index bfab65d57..5d1ba0683 100644 --- a/src/tools/glob/cli.test.ts +++ b/src/tools/glob/cli.test.ts @@ -54,6 +54,15 @@ describe("buildRgArgs", () => { const args = buildRgArgs({ pattern: "**/*.tsx" }) expect(args).toContain("--glob=**/*.tsx") }) + + // Regression for #3726: broken/dangling symlinks should not surface as + // tool errors. --no-messages silences ripgrep's non-fatal stderr warnings + // so the downstream "exit code > 2 && stderr.trim()" gate sees a clean + // stream when only I/O warnings were emitted. + it("includes --no-messages so broken symlinks do not error the tool (#3726)", () => { + const args = buildRgArgs({ pattern: "*.ts" }) + expect(args).toContain("--no-messages") + }) }) describe("buildFindArgs", () => { diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts index 9ba34c32a..271405a21 100644 --- a/src/tools/glob/cli.ts +++ b/src/tools/glob/cli.ts @@ -152,7 +152,10 @@ async function runRgFilesInternal( const stderr = await new Response(proc.stderr).text() const exitCode = await proc.exited - if (exitCode > 1 && stderr.trim()) { + // ripgrep exits 2 on non-fatal I/O errors (broken symlinks, permission + // denied) even when stdout still contains valid file matches; treat only + // exit codes >2 as fatal so partial results survive. See #3726. + if (exitCode > 2 && stderr.trim()) { return { files: [], totalFiles: 0, diff --git a/src/tools/glob/constants.ts b/src/tools/glob/constants.ts index 8284b3681..0e49431f4 100644 --- a/src/tools/glob/constants.ts +++ b/src/tools/glob/constants.ts @@ -9,4 +9,8 @@ export const RG_FILES_FLAGS = [ "--files", "--color=never", "--glob=!.git/*", + // Suppress stderr warnings on broken/dangling symlinks and similar + // non-fatal I/O issues so they don't tip the tool into the error branch. + // See #3726. + "--no-messages", ] as const diff --git a/src/tools/grep/cli.ts b/src/tools/grep/cli.ts index 4b9684c66..abef23804 100644 --- a/src/tools/grep/cli.ts +++ b/src/tools/grep/cli.ts @@ -197,7 +197,10 @@ async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): P const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout - if (exitCode > 1 && stderr.trim()) { + // ripgrep exits 2 on non-fatal I/O errors (broken symlinks, permission + // denied) even when stdout still contains valid matches; treat only + // exit codes >2 as fatal so partial results survive. See #3726. + if (exitCode > 2 && stderr.trim()) { return { matches: [], totalMatches: 0, diff --git a/src/tools/grep/constants.ts b/src/tools/grep/constants.ts index f6c913303..5dc9f34f3 100644 --- a/src/tools/grep/constants.ts +++ b/src/tools/grep/constants.ts @@ -12,6 +12,10 @@ export const RG_SAFETY_FLAGS = [ "--no-heading", "--line-number", "--with-filename", + // Suppress stderr warnings on broken/dangling symlinks and similar + // non-fatal I/O issues so they don't tip the tool into the error branch. + // See #3726. + "--no-messages", ] as const export const GREP_SAFETY_FLAGS = ["-n", "-H", "--color=never"] as const From 8bba7357b1ad7022d395cdb564caddef7a9b4cdb Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sun, 17 May 2026 11:21:28 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(glob,grep):=20keep=20exit-code=20gate?= =?UTF-8?q?=20at=20>1=20=E2=80=94=20--no-messages=20alone=20is=20enough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses cubic-dev-ai P1 + P2 findings on #4115. The original PR relaxed `exitCode > 1` to `> 2` based on the (wrong) claim that ripgrep exits 2 only on non-fatal I/O issues. ripgrep actually uses exit code 2 for BOTH fatal errors (pattern syntax, invalid args) AND non-fatal I/O issues; GNU grep (the fallback backend in grep/cli.ts) likewise uses 2 for fatal errors. So `> 2` would silently suppress fatal errors. The correct fix is just `--no-messages`, which suppresses ripgrep's stderr only for soft I/O issues (broken symlinks, permission denied) while leaving fatal-error messages intact. With the gate kept at `exitCode > 1 && stderr.trim()`: - Broken symlink: ripgrep exits 2, stderr is empty (suppressed) → `stderr.trim()` is falsy → gate fails → partial results survive. - Fatal error: ripgrep exits 2, stderr has the real error message (not suppressed by --no-messages) → gate triggers → error returned. Reverting both `exitCode > 1` → `> 2` changes; keeping the `--no-messages` flag additions and the regression test (test comment updated to describe the cleaner architecture). Verification: bun test src/tools/glob/ src/tools/grep/ → 30 pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/glob/cli.test.ts | 6 ++++-- src/tools/glob/cli.ts | 5 +---- src/tools/grep/cli.ts | 5 +---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/tools/glob/cli.test.ts b/src/tools/glob/cli.test.ts index 5d1ba0683..dcc393eb4 100644 --- a/src/tools/glob/cli.test.ts +++ b/src/tools/glob/cli.test.ts @@ -57,8 +57,10 @@ describe("buildRgArgs", () => { // Regression for #3726: broken/dangling symlinks should not surface as // tool errors. --no-messages silences ripgrep's non-fatal stderr warnings - // so the downstream "exit code > 2 && stderr.trim()" gate sees a clean - // stream when only I/O warnings were emitted. + // for I/O issues (broken symlinks, permission denied) without suppressing + // fatal-error messages, so the existing "exit code > 1 && stderr.trim()" + // gate sees a clean stream for soft I/O issues but still triggers on real + // fatal ripgrep errors. it("includes --no-messages so broken symlinks do not error the tool (#3726)", () => { const args = buildRgArgs({ pattern: "*.ts" }) expect(args).toContain("--no-messages") diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts index 271405a21..9ba34c32a 100644 --- a/src/tools/glob/cli.ts +++ b/src/tools/glob/cli.ts @@ -152,10 +152,7 @@ async function runRgFilesInternal( const stderr = await new Response(proc.stderr).text() const exitCode = await proc.exited - // ripgrep exits 2 on non-fatal I/O errors (broken symlinks, permission - // denied) even when stdout still contains valid file matches; treat only - // exit codes >2 as fatal so partial results survive. See #3726. - if (exitCode > 2 && stderr.trim()) { + if (exitCode > 1 && stderr.trim()) { return { files: [], totalFiles: 0, diff --git a/src/tools/grep/cli.ts b/src/tools/grep/cli.ts index abef23804..4b9684c66 100644 --- a/src/tools/grep/cli.ts +++ b/src/tools/grep/cli.ts @@ -197,10 +197,7 @@ async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): P const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout - // ripgrep exits 2 on non-fatal I/O errors (broken symlinks, permission - // denied) even when stdout still contains valid matches; treat only - // exit codes >2 as fatal so partial results survive. See #3726. - if (exitCode > 2 && stderr.trim()) { + if (exitCode > 1 && stderr.trim()) { return { matches: [], totalMatches: 0,