Replace spawn with execFile for memory-safe command execution (#1068)

This commit is contained in:
tanzhenxin
2025-11-20 15:04:00 +08:00
committed by GitHub
parent a15b84e2a1
commit 442a9aed58
20 changed files with 620 additions and 969 deletions

View File

@@ -18,19 +18,68 @@ import * as glob from 'glob';
vi.mock('glob', { spy: true });
// Mock the child_process module to control grep/git grep behavior
vi.mock('child_process', () => ({
spawn: vi.fn(() => ({
on: (event: string, cb: (...args: unknown[]) => void) => {
if (event === 'error' || event === 'close') {
// Simulate command not found or error for git grep and system grep
// to force it to fall back to JS implementation.
setTimeout(() => cb(1), 0); // cb(1) for error/close
}
},
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
})),
}));
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: vi.fn(() => {
// Create a proper mock EventEmitter-like child process
const listeners: Map<
string,
Set<(...args: unknown[]) => void>
> = new Map();
const createStream = () => ({
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
const key = `stream:${event}`;
if (!listeners.has(key)) listeners.set(key, new Set());
listeners.get(key)!.add(cb);
}),
removeListener: vi.fn(
(event: string, cb: (...args: unknown[]) => void) => {
const key = `stream:${event}`;
listeners.get(key)?.delete(cb);
},
),
});
return {
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
const key = `child:${event}`;
if (!listeners.has(key)) listeners.set(key, new Set());
listeners.get(key)!.add(cb);
// Simulate command not found or error for git grep and system grep
// to force it to fall back to JS implementation.
if (event === 'error') {
setTimeout(() => cb(new Error('Command not found')), 0);
} else if (event === 'close') {
setTimeout(() => cb(1), 0); // Exit code 1 for error
}
}),
removeListener: vi.fn(
(event: string, cb: (...args: unknown[]) => void) => {
const key = `child:${event}`;
listeners.get(key)?.delete(cb);
},
),
stdout: createStream(),
stderr: createStream(),
connected: false,
disconnect: vi.fn(),
};
}),
exec: vi.fn(
(
cmd: string,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
// Mock exec to fail for git grep commands
callback(new Error('Command not found'), '', '');
},
),
};
});
describe('GrepTool', () => {
let tempRootDir: string;

View File

@@ -18,6 +18,7 @@ import { isGitRepository } from '../utils/gitUtils.js';
import type { Config } from '../config/config.js';
import type { FileExclusions } from '../utils/ignorePatterns.js';
import { ToolErrorType } from './tool-error.js';
import { isCommandAvailable } from '../utils/shell-utils.js';
// --- Interfaces ---
@@ -195,29 +196,6 @@ class GrepToolInvocation extends BaseToolInvocation<
}
}
/**
* Checks if a command is available in the system's PATH.
* @param {string} command The command name (e.g., 'git', 'grep').
* @returns {Promise<boolean>} True if the command is available, false otherwise.
*/
private isCommandAvailable(command: string): Promise<boolean> {
return new Promise((resolve) => {
const checkCommand = process.platform === 'win32' ? 'where' : 'command';
const checkArgs =
process.platform === 'win32' ? [command] : ['-v', command];
try {
const child = spawn(checkCommand, checkArgs, {
stdio: 'ignore',
shell: process.platform === 'win32',
});
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
} catch {
resolve(false);
}
});
}
/**
* Parses the standard output of grep-like commands (git grep, system grep).
* Expects format: filePath:lineNumber:lineContent
@@ -297,7 +275,7 @@ class GrepToolInvocation extends BaseToolInvocation<
try {
// --- Strategy 1: git grep ---
const isGit = isGitRepository(absolutePath);
const gitAvailable = isGit && (await this.isCommandAvailable('git'));
const gitAvailable = isGit && isCommandAvailable('git').available;
if (gitAvailable) {
strategyUsed = 'git grep';
@@ -350,7 +328,7 @@ class GrepToolInvocation extends BaseToolInvocation<
}
// --- Strategy 2: System grep ---
const grepAvailable = await this.isCommandAvailable('grep');
const { available: grepAvailable } = isCommandAvailable('grep');
if (grepAvailable) {
strategyUsed = 'system grep';
const grepArgs = ['-r', '-n', '-H', '-E'];

View File

@@ -20,14 +20,13 @@ import fs from 'node:fs/promises';
import os, { EOL } from 'node:os';
import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
import { runRipgrep } from '../utils/ripgrepUtils.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
// Mock ripgrepUtils
vi.mock('../utils/ripgrepUtils.js', () => ({
getRipgrepCommand: vi.fn(),
runRipgrep: vi.fn(),
}));
// Mock child_process for ripgrep calls
@@ -37,60 +36,6 @@ vi.mock('child_process', () => ({
const mockSpawn = vi.mocked(spawn);
// Helper function to create mock spawn implementations
function createMockSpawn(
options: {
outputData?: string;
exitCode?: number;
signal?: string;
onCall?: (
command: string,
args: readonly string[],
spawnOptions?: unknown,
) => void;
} = {},
) {
const { outputData, exitCode = 0, signal, onCall } = options;
return (command: string, args: readonly string[], spawnOptions?: unknown) => {
onCall?.(command, args, spawnOptions);
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
// Set up event listeners immediately
setTimeout(() => {
const stdoutDataHandler = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const closeHandler = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (stdoutDataHandler && outputData) {
stdoutDataHandler(Buffer.from(outputData));
}
if (closeHandler) {
closeHandler(exitCode, signal);
}
}, 0);
return mockProcess as unknown as ChildProcess;
};
}
describe('RipGrepTool', () => {
let tempRootDir: string;
let grepTool: RipGrepTool;
@@ -109,7 +54,6 @@ describe('RipGrepTool', () => {
beforeEach(async () => {
vi.clearAllMocks();
(getRipgrepCommand as Mock).mockResolvedValue('/mock/path/to/rg');
mockSpawn.mockReset();
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
fileExclusionsMock = {
@@ -200,12 +144,11 @@ describe('RipGrepTool', () => {
describe('execute', () => {
it('should find matches for a simple pattern in all files', async () => {
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}sub/fileC.txt:1:another world in sub dir${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}sub/fileC.txt:1:another world in sub dir${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
@@ -223,12 +166,11 @@ describe('RipGrepTool', () => {
it('should find matches in a specific path', async () => {
// Setup specific mock for this test - searching in 'sub' should only return matches from that directory
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileC.txt:1:another world in sub dir${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileC.txt:1:another world in sub dir${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'world', path: 'sub' };
const invocation = grepTool.build(params);
@@ -243,16 +185,11 @@ describe('RipGrepTool', () => {
});
it('should use target directory when path is not provided', async () => {
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileA.txt:1:hello world${EOL}`,
exitCode: 0,
onCall: (_, args) => {
// Should search in the target directory (tempRootDir)
expect(args[args.length - 1]).toBe(tempRootDir);
},
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileA.txt:1:hello world${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
@@ -264,12 +201,11 @@ describe('RipGrepTool', () => {
it('should find matches with a glob filter', async () => {
// Setup specific mock for this test
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileB.js:2:function baz() { return "hello"; }${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileB.js:2:function baz() { return "hello"; }${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'hello', glob: '*.js' };
const invocation = grepTool.build(params);
@@ -290,39 +226,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - searching for 'hello' in 'sub' with '*.js' filter
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
// Only return match from the .js file in sub directory
onData(Buffer.from(`another.js:1:const greeting = "hello";${EOL}`));
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `another.js:1:const greeting = "hello";${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = {
@@ -346,15 +253,11 @@ describe('RipGrepTool', () => {
path.join(tempRootDir, '.qwenignore'),
'ignored.txt\n',
);
mockSpawn.mockImplementationOnce(
createMockSpawn({
exitCode: 1,
onCall: (_, args) => {
expect(args).toContain('--ignore-file');
expect(args).toContain(path.join(tempRootDir, '.qwenignore'));
},
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'secret' };
const invocation = grepTool.build(params);
@@ -375,16 +278,11 @@ describe('RipGrepTool', () => {
}),
});
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `kept.txt:1:keep me${EOL}`,
exitCode: 0,
onCall: (_, args) => {
expect(args).not.toContain('--ignore-file');
expect(args).not.toContain(path.join(tempRootDir, '.qwenignore'));
},
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `kept.txt:1:keep me${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'keep' };
const invocation = grepTool.build(params);
@@ -404,14 +302,11 @@ describe('RipGrepTool', () => {
}),
});
mockSpawn.mockImplementationOnce(
createMockSpawn({
exitCode: 1,
onCall: (_, args) => {
expect(args).toContain('--no-ignore-vcs');
},
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'ignored' };
const invocation = grepTool.build(params);
@@ -421,12 +316,11 @@ describe('RipGrepTool', () => {
it('should truncate llm content when exceeding maximum length', async () => {
const longMatch = 'fileA.txt:1:' + 'a'.repeat(30_000);
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `${longMatch}${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `${longMatch}${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'a+' };
const invocation = grepTool.build(params);
@@ -439,11 +333,11 @@ describe('RipGrepTool', () => {
it('should return "No matches found" when pattern does not exist', async () => {
// Setup specific mock for no matches
mockSpawn.mockImplementationOnce(
createMockSpawn({
exitCode: 1, // No matches found
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'nonexistentpattern' };
const invocation = grepTool.build(params);
@@ -463,39 +357,10 @@ describe('RipGrepTool', () => {
it('should handle regex special characters correctly', async () => {
// Setup specific mock for this test - regex pattern 'foo.*bar' should match 'const foo = "bar";'
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
// Return match for the regex pattern
onData(Buffer.from(`fileB.js:1:const foo = "bar";${EOL}`));
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileB.js:1:const foo = "bar";${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'foo.*bar' }; // Matches 'const foo = "bar";'
@@ -509,43 +374,10 @@ describe('RipGrepTool', () => {
it('should be case-insensitive by default (JS fallback)', async () => {
// Setup specific mock for this test - case insensitive search for 'HELLO'
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
// Return case-insensitive matches for 'HELLO'
onData(
Buffer.from(
`fileA.txt:1:hello world${EOL}fileB.js:2:function baz() { return "hello"; }${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileA.txt:1:hello world${EOL}fileB.js:2:function baz() { return "hello"; }${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'HELLO' };
@@ -568,12 +400,11 @@ describe('RipGrepTool', () => {
});
it('should search within a single file when path is a file', async () => {
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = {
pattern: 'world',
@@ -588,7 +419,11 @@ describe('RipGrepTool', () => {
});
it('should throw an error if ripgrep is not available', async () => {
(getRipgrepCommand as Mock).mockResolvedValue(null);
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: new Error('ripgrep binary not found.'),
});
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
@@ -612,54 +447,6 @@ describe('RipGrepTool', () => {
const result = await invocation.execute(controller.signal);
expect(result).toBeDefined();
});
it('should abort streaming search when signal is triggered', async () => {
// Setup specific mock for this test - simulate process being killed due to abort
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
// Simulate process being aborted - use setTimeout to ensure handlers are registered first
setTimeout(() => {
const closeHandler = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (closeHandler) {
// Simulate process killed by signal (code is null, signal is SIGTERM)
closeHandler(null, 'SIGTERM');
}
}, 0);
return mockProcess as unknown as ChildProcess;
});
const controller = new AbortController();
const params: RipGrepToolParams = { pattern: 'test' };
const invocation = grepTool.build(params);
// Abort immediately before starting the search
controller.abort();
const result = await invocation.execute(controller.signal);
expect(result.llmContent).toContain(
'Error during grep search operation: ripgrep exited with code null',
);
expect(result.returnDisplay).toContain(
'Error: ripgrep exited with code null',
);
});
});
describe('error handling and edge cases', () => {
@@ -675,32 +462,10 @@ describe('RipGrepTool', () => {
await fs.mkdir(emptyDir);
// Setup specific mock for this test - searching in empty directory should return no matches
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onClose) {
onClose(1);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'test', path: 'empty' };
@@ -715,32 +480,10 @@ describe('RipGrepTool', () => {
await fs.writeFile(path.join(tempRootDir, 'empty.txt'), '');
// Setup specific mock for this test - searching for anything in empty files should return no matches
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onClose) {
onClose(1);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'anything' };
@@ -758,42 +501,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - searching for 'world' should find the file with special characters
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`${specialFileName}:1:hello world with special chars${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `file with spaces & symbols!.txt:1:hello world with special chars${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'world' };
@@ -813,42 +524,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - searching for 'deep' should find the deeply nested file
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`a/b/c/d/e/deep.txt:1:content in deep directory${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `a/b/c/d/e/deep.txt:1:content in deep directory${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'deep' };
@@ -868,42 +547,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - regex pattern should match function declarations
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`code.js:1:function getName() { return "test"; }${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `code.js:1:function getName() { return "test"; }${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'function\\s+\\w+\\s*\\(' };
@@ -921,42 +568,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - case insensitive search should match all variants
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`case.txt:1:Hello World${EOL}case.txt:2:hello world${EOL}case.txt:3:HELLO WORLD${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `case.txt:1:Hello World${EOL}case.txt:2:hello world${EOL}case.txt:3:HELLO WORLD${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'hello' };
@@ -975,38 +590,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - escaped regex pattern should match price format
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(Buffer.from(`special.txt:1:Price: $19.99${EOL}`));
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `special.txt:1:Price: $19.99${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: '\\$\\d+\\.\\d+' };
@@ -1032,42 +619,10 @@ describe('RipGrepTool', () => {
await fs.writeFile(path.join(tempRootDir, 'test.txt'), 'text content');
// Setup specific mock for this test - glob pattern should filter to only ts/tsx files
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`test.ts:1:typescript content${EOL}test.tsx:1:tsx content${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `test.ts:1:typescript content${EOL}test.tsx:1:tsx content${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = {
@@ -1092,38 +647,10 @@ describe('RipGrepTool', () => {
await fs.writeFile(path.join(tempRootDir, 'other.ts'), 'other code');
// Setup specific mock for this test - glob pattern should filter to only src/** files
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(Buffer.from(`src/main.ts:1:source code${EOL}`));
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `src/main.ts:1:source code${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = {

View File

@@ -6,14 +6,13 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import { resolveAndValidatePath } from '../utils/paths.js';
import { getErrorMessage } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
import { runRipgrep } from '../utils/ripgrepUtils.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import type { FileFilteringOptions } from '../config/constants.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
@@ -208,60 +207,12 @@ class GrepToolInvocation extends BaseToolInvocation<
rgArgs.push('--threads', '4');
rgArgs.push(absolutePath);
try {
const rgCommand = await getRipgrepCommand(
this.config.getUseBuiltinRipgrep(),
);
if (!rgCommand) {
throw new Error('ripgrep binary not found.');
}
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(rgCommand, rgArgs, {
windowsHide: true,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const cleanup = () => {
if (options.signal.aborted) {
child.kill();
}
};
options.signal.addEventListener('abort', cleanup, { once: true });
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
child.on('error', (err) => {
options.signal.removeEventListener('abort', cleanup);
reject(new Error(`failed to start ripgrep: ${err.message}.`));
});
child.on('close', (code) => {
options.signal.removeEventListener('abort', cleanup);
const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
const stderrData = Buffer.concat(stderrChunks).toString('utf8');
if (code === 0) {
resolve(stdoutData);
} else if (code === 1) {
resolve(''); // No matches found
} else {
reject(
new Error(`ripgrep exited with code ${code}: ${stderrData}`),
);
}
});
});
return output;
} catch (error: unknown) {
console.error(`Ripgrep failed: ${getErrorMessage(error)}`);
throw error;
const result = await runRipgrep(rgArgs, options.signal);
if (result.error && !result.stdout) {
throw result.error;
}
return result.stdout;
}
private getFileFilteringOptions(): FileFilteringOptions {