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
59 changes: 43 additions & 16 deletions src/tools/search-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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<string[] | null> {
if (!(await isRgAvailable())) return null;
Expand All @@ -71,6 +74,7 @@ export async function grepWithRg(opts: GrepWithRgOptions): Promise<string[] | nu
contextLines,
outputMode,
multiline,
onIncomplete,
} = opts;

const args: string[] = ['--color=never'];
Expand Down Expand Up @@ -122,8 +126,12 @@ export async function grepWithRg(opts: GrepWithRgOptions): Promise<string[] | nu
if (err.exitCode === 1) return [];
// A bounded scan of / or $HOME hits unreadable directories (exit 2)
// and may hit the time cap. Keep the lines rg already printed.
if (maxDepth !== undefined && err.stdout.trim()) stdout = err.stdout;
else return null;
// Even an empty bounded result has spent this scan's budget. Falling
// back would renew it and can block on an unreadable directory.
if (maxDepth !== undefined) {
stdout = err.stdout;
onIncomplete?.();
} else return null;
} else {
return null;
}
Expand Down Expand Up @@ -177,14 +185,16 @@ export async function grepWithRg(opts: GrepWithRgOptions): Promise<string[] | nu
/**
* Find files via `rg --files` (respects .gitignore). Returns absolute paths
* sorted by mtime descending (Claude Code Glob parity — recent files first).
* Returns null when rg is unavailable.
* Returns null when rg is unavailable. Failed bounded attempts retain output
* and notify onIncomplete instead of requesting another scan.
*/
export async function filesWithRg(
searchDir: string,
pattern: string,
limit: number,
timeoutMs = 30_000,
maxDepth?: number
maxDepth?: number,
onIncomplete?: () => void
): Promise<string[] | null> {
if (!(await isRgAvailable())) return null;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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);
}
Expand Down Expand Up @@ -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({
Expand All @@ -607,6 +629,9 @@ export const searchCodeTool: Tool = {
contextLines: outputMode === 'content' ? contextLines : 0,
outputMode,
multiline,
onIncomplete: () => {
incomplete = true;
},
...(maxDepth !== undefined ? { maxDepth } : {}),
});

Expand All @@ -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);
}
Expand Down
67 changes: 67 additions & 0 deletions test/search-bounded-rg-failure.spec.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
}
);
}
}
Loading