From bb04408e9b49a214d540a6406b5204c8dfb14aac Mon Sep 17 00:00:00 2001 From: lx-1997 <13500790262@163.com> Date: Thu, 29 Oct 2026 00:17:52 +0800 Subject: [PATCH] fix(search): retain incomplete bounded rg attempts without rescanning --- src/tools/search-tools.ts | 59 ++++++++++++++++------ test/search-bounded-rg-failure.spec.mjs | 67 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 test/search-bounded-rg-failure.spec.mjs diff --git a/src/tools/search-tools.ts b/src/tools/search-tools.ts index b96ddd80..de71d2b2 100644 --- a/src/tools/search-tools.ts +++ b/src/tools/search-tools.ts @@ -43,6 +43,8 @@ export interface GrepWithRgOptions { contextLines: number; outputMode: SearchCodeOutputMode; multiline?: boolean; + /** Report incomplete bounded output without changing the array return contract. */ + onIncomplete?: () => void; } /** @@ -51,8 +53,9 @@ export interface GrepWithRgOptions { * - files_with_matches: one relative path per line * - count: `path: N` per file * - * Returns null when rg is unavailable or fails hard (caller falls back). - * Empty array when there are no matches. + * Returns null when rg is unavailable or an unbounded invocation fails hard. + * A failed bounded invocation retains output, including an empty array, and + * calls onIncomplete; the caller must not treat that as a complete scan. */ export async function grepWithRg(opts: GrepWithRgOptions): Promise { if (!(await isRgAvailable())) return null; @@ -71,6 +74,7 @@ export async function grepWithRg(opts: GrepWithRgOptions): Promise void ): Promise { if (!(await isRgAvailable())) return null; @@ -212,8 +222,10 @@ export async function filesWithRg( } catch (err) { if (err instanceof ProcessError) { if (err.exitCode === 1) return []; - if (maxDepth !== undefined && err.stdout.trim()) stdout = err.stdout; - else return null; + if (maxDepth !== undefined) { + stdout = err.stdout; + onIncomplete?.(); + } else return null; } else { return null; } @@ -411,7 +423,13 @@ function withBoundNote(text: string, location: SearchLocation): string { return `${text}\n${location.bound.note}`; } -function presentSearch(text: string, location: SearchLocation): string { +function presentSearch(text: string, location: SearchLocation, incomplete = false): string { + if (incomplete) { + if (text === 'No files found' || text === 'No matches found') + text = 'No matching output collected.'; + text += + '\nIncomplete bounded search: rg ended with errors or a time limit. Results may be empty or partial; narrow the search path.'; + } return redactEgress(withBoundNote(text, location)); } @@ -462,7 +480,10 @@ export const searchFilesTool: Tool = { const maxDepth = location.bound?.maxDepth; // Prefer rg --files: gitignore-aware and fast on monorepos. - let absPaths = await filesWithRg(location.dir, pattern, limit, timeoutMs, maxDepth); + let incomplete = false; + let absPaths = await filesWithRg(location.dir, pattern, limit, timeoutMs, maxDepth, () => { + incomplete = true; + }); if (absPaths === null) { absPaths = await walkMatch(location.dir, pattern, limit, { ...(maxDepth !== undefined ? { maxDepth, timeoutMs } : {}), @@ -472,12 +493,12 @@ export const searchFilesTool: Tool = { const relative = absPaths.map((file) => path.relative(ctx.workspaceDir, file).split(path.sep).join('/') ); - if (relative.length === 0) return presentSearch('No files found', location); + if (relative.length === 0) return presentSearch('No files found', location, incomplete); const truncated = absPaths.length >= limit; const header = truncated ? `Found ${relative.length}+ files (showing first ${limit}, newest first):\n` : `Found ${relative.length} file(s) (newest first):\n`; - return presentSearch(header + relative.join('\n'), location); + return presentSearch(header + relative.join('\n'), location, incomplete); } catch (err) { throw toolError('Error searching files', err); } @@ -592,6 +613,7 @@ export const searchCodeTool: Tool = { : maxResults; const timeoutMs = location.bound?.timeoutMs ?? 30_000; const maxDepth = location.bound?.maxDepth; + let incomplete = false; // Prefer ripgrep when available: respects .gitignore and is far // faster than the in-process walk on large repos. const rgResults = await grepWithRg({ @@ -607,6 +629,9 @@ export const searchCodeTool: Tool = { contextLines: outputMode === 'content' ? contextLines : 0, outputMode, multiline, + onIncomplete: () => { + incomplete = true; + }, ...(maxDepth !== undefined ? { maxDepth } : {}), }); @@ -631,20 +656,22 @@ export const searchCodeTool: Tool = { } ); } - if (matches.length === 0) return presentSearch('No matches found', location); + if (matches.length === 0) return presentSearch('No matches found', location, incomplete); if (outputMode === 'files_with_matches') { return presentSearch( `Files with matches (${matches.length}):\n${matches.join('\n')}`, - location + location, + incomplete ); } if (outputMode === 'count') { return presentSearch( `Match counts (${matches.length} files):\n${matches.join('\n')}`, - location + location, + incomplete ); } - return presentSearch(matches.join('\n'), location); + return presentSearch(matches.join('\n'), location, incomplete); } catch (err) { throw toolError('Error searching code', err); } diff --git a/test/search-bounded-rg-failure.spec.mjs b/test/search-bounded-rg-failure.spec.mjs new file mode 100644 index 00000000..0d44921e --- /dev/null +++ b/test/search-bounded-rg-failure.spec.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const moduleUrl = new URL('../dist/tools/search-tools.js', import.meta.url).href; +for (const kind of ['files', 'code']) { + for (const mode of [ + 'empty-error', + 'partial-error', + 'empty-timeout', + 'partial-timeout', + 'unbounded-error', + ]) { + test( + `${kind}: rg ${mode} keeps the correct fallback decision`, + { + skip: + process.platform === 'win32' + ? 'POSIX executable fixture; Windows retains its real search integration suite' + : false, + }, + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-rg-bound-')); + try { + const bin = path.join(root, 'bin'); + const cwd = path.join(root, 'work'); + const search = path.join(root, 'search'); + await Promise.all([bin, cwd, search].map((p) => fs.mkdir(p))); + const file = path.join(search, 'needle.txt'); + await fs.writeFile(file, 'needle\n'); + const output = kind === 'files' ? file + '\n' : file + ':1:needle\n'; + await fs.writeFile( + path.join(bin, 'rg'), + `#!${process.execPath}\n${mode.startsWith('partial') ? `process.stdout.write(${JSON.stringify(output)});` : ''}\n${mode.endsWith('timeout') ? 'setInterval(()=>{},1000);' : 'process.exitCode=2;'}\n`, + { mode: 0o700 } + ); + const depth = mode === 'unbounded-error' ? 'undefined' : '2'; + const code = `const m=await import(${JSON.stringify(moduleUrl)}); let incomplete=false; const onIncomplete=()=>{incomplete=true}; const bounded=${kind === 'files' ? `await m.filesWithRg(${JSON.stringify(search)},'*.txt',40,1000,${depth},onIncomplete)` : `await m.grepWithRg({searchDir:${JSON.stringify(search)},pattern:'needle',extensions:null,maxDepth:${depth},limit:40,timeoutMs:1000,displayRoot:${JSON.stringify(search)},caseSensitive:true,contextLines:0,outputMode:'content',onIncomplete})`}; const message=${!mode.endsWith('timeout') && mode !== 'unbounded-error' ? `await m.${kind === 'files' ? 'searchFilesTool' : 'searchCodeTool'}.execute({pattern:'needle',path:'/'},{workspaceDir:${JSON.stringify(cwd)}})` : 'null'}; console.log(JSON.stringify({matches:bounded,incomplete,message}));`; + const env = { ...process.env, PATH: bin + path.delimiter + process.env.PATH }; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', code], { + cwd, + env, + encoding: 'utf8', + timeout: 5000, + }); + assert.equal(result.status, 0, result.stderr); + const { matches, incomplete, message } = JSON.parse(result.stdout); + assert.equal(incomplete, mode !== 'unbounded-error'); + if (message !== null) { + assert.match(message, /Incomplete bounded search/); + assert.doesNotMatch(message, /No files found|No matches found/); + } + if (mode === 'unbounded-error') assert.equal(matches, null); + else { + assert.ok(Array.isArray(matches), 'null starts another walk and renews the budget'); + assert.equal(matches.length, mode.startsWith('partial') ? 1 : 0); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + } + ); + } +}