mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4bad90506 | |||
| 8477ec2a93 | |||
| 66fe872982 | |||
| b785bd4da0 | |||
| 6f52a7fd3e | |||
| c8403c49dc | |||
| 6855d94828 | |||
| 6ca188ad2f | |||
| cb780f4399 | |||
| 0be31d1181 | |||
| 1be4488592 | |||
| 60e3b62292 | |||
| 13251f1e27 | |||
| aaa2661917 | |||
| 28afd9bbff |
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import { GrepTool } from '../packages/core/src/tools/grep.js';
|
||||
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
|
||||
import { Config } from '../packages/core/src/config/config.js';
|
||||
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
|
||||
|
||||
// Mock Config to provide necessary context for both GrepTool and RipGrepTool
|
||||
class MockConfig {
|
||||
constructor(private targetDir: string) {}
|
||||
|
||||
getTargetDir() {
|
||||
return this.targetDir;
|
||||
}
|
||||
|
||||
getWorkspaceContext() {
|
||||
return new WorkspaceContext(this.targetDir, [this.targetDir]);
|
||||
}
|
||||
|
||||
getDebugMode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringRespectGeminiIgnore() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringOptions() {
|
||||
return {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
getFileExclusions() {
|
||||
return {
|
||||
getGlobExcludes: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
validatePathAccess() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Search Tools Integration', () => {
|
||||
describe('RipGrepTool', () => {
|
||||
let tempDir: string;
|
||||
let tool: RipGrepTool;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-test-'));
|
||||
|
||||
// Create test files
|
||||
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'hello world\n');
|
||||
await fs.mkdir(path.join(tempDir, 'subdir'));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'subdir', 'file2.txt'),
|
||||
'hello universe\n',
|
||||
);
|
||||
await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n');
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'script.js'),
|
||||
'console.log("hello");\n',
|
||||
);
|
||||
|
||||
// Create a file with multiple matches for limits testing
|
||||
const manyMatches = `
|
||||
match 1
|
||||
filler
|
||||
match 2
|
||||
filler
|
||||
match 3
|
||||
filler
|
||||
match 4
|
||||
match 5
|
||||
`;
|
||||
await fs.writeFile(path.join(tempDir, 'many_matches.txt'), manyMatches);
|
||||
|
||||
const config = new MockConfig(tempDir) as unknown as Config;
|
||||
tool = new RipGrepTool(config);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Basic Functionality', () => {
|
||||
it('should find matches using the real ripgrep binary', async () => {
|
||||
const invocation = tool.build({ pattern: 'hello' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 3 matches'); // file1, file2, script.js
|
||||
expect(result.llmContent).toContain('file1.txt');
|
||||
expect(result.llmContent).toContain('L1: hello world');
|
||||
expect(result.llmContent).toContain('subdir');
|
||||
expect(result.llmContent).toContain('file2.txt');
|
||||
expect(result.llmContent).toContain('L1: hello universe');
|
||||
expect(result.llmContent).not.toContain('goodbye moon');
|
||||
});
|
||||
|
||||
it('should handle no matches correctly', async () => {
|
||||
const invocation = tool.build({ pattern: 'nonexistent_pattern_123' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('No matches found');
|
||||
});
|
||||
|
||||
it('should respect include filters', async () => {
|
||||
const invocation = tool.build({ pattern: 'hello', include: '*.js' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('script.js');
|
||||
expect(result.llmContent).not.toContain('file1.txt');
|
||||
});
|
||||
|
||||
it('should return context lines when requested', async () => {
|
||||
const invocation = tool.build({ pattern: 'match 1', context: 1 });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('match 1');
|
||||
expect(result.llmContent).toContain('filler'); // Context line
|
||||
});
|
||||
});
|
||||
|
||||
describe('Limits', () => {
|
||||
it('should limit matches per file when max_matches_per_file is set', async () => {
|
||||
const invocation = tool.build({
|
||||
pattern: 'match',
|
||||
max_matches_per_file: 2,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
expect(result.llmContent).toContain('many_matches.txt');
|
||||
expect(result.llmContent).toContain('match 1');
|
||||
expect(result.llmContent).toContain('match 2');
|
||||
expect(result.llmContent).not.toContain('match 3');
|
||||
expect(result.llmContent).not.toContain('match 4');
|
||||
});
|
||||
|
||||
it('should return all matches when max_matches_per_file is not set', async () => {
|
||||
// We test this on a file that has more matches than the previous limit
|
||||
const invocation = tool.build({ pattern: 'match' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('match 1');
|
||||
expect(result.llmContent).toContain('match 2');
|
||||
expect(result.llmContent).toContain('match 3');
|
||||
expect(result.llmContent).toContain('match 4');
|
||||
expect(result.llmContent).toContain('match 5');
|
||||
});
|
||||
|
||||
it('should limit total matches when total_max_matches is set', async () => {
|
||||
const invocation = tool.build({
|
||||
pattern: 'match',
|
||||
total_max_matches: 3,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 3 matches');
|
||||
expect(result.llmContent).toContain('match 1');
|
||||
expect(result.llmContent).toContain('match 2');
|
||||
expect(result.llmContent).toContain('match 3');
|
||||
expect(result.llmContent).not.toContain('match 4');
|
||||
expect(result.llmContent).not.toContain('match 5');
|
||||
expect(result.llmContent).toContain(
|
||||
'(results limited to 3 matches for performance)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default limit when total_max_matches is not set', async () => {
|
||||
const invocation = tool.build({ pattern: 'match' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
// Just verify it found everything available (5 matches)
|
||||
expect(result.llmContent).toContain('Found 5 matches');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GrepTool', () => {
|
||||
let tempDir: string;
|
||||
let tool: GrepTool;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-test-'));
|
||||
|
||||
// Create a test file with multiple matches
|
||||
const content = `
|
||||
match 1
|
||||
match 2
|
||||
match 3
|
||||
match 4
|
||||
match 5
|
||||
`;
|
||||
await fs.writeFile(path.join(tempDir, 'many_matches.txt'), content);
|
||||
|
||||
const config = new MockConfig(tempDir) as unknown as Config;
|
||||
tool = new GrepTool(config, null!);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should limit total matches when total_max_matches is set', async () => {
|
||||
const invocation = tool.build({
|
||||
pattern: 'match',
|
||||
total_max_matches: 3,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 3 matches');
|
||||
expect(result.llmContent).toContain('match 1');
|
||||
expect(result.llmContent).toContain('match 2');
|
||||
expect(result.llmContent).toContain('match 3');
|
||||
expect(result.llmContent).not.toContain('match 4');
|
||||
expect(result.llmContent).not.toContain('match 5');
|
||||
expect(result.llmContent).toContain(
|
||||
'(results limited to 3 matches for performance)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+1
-24
@@ -2253,7 +2253,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2434,7 +2433,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2468,7 +2466,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2837,7 +2834,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2871,7 +2867,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1"
|
||||
@@ -2924,7 +2919,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1",
|
||||
@@ -4140,7 +4134,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4425,7 +4418,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5418,7 +5410,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -8428,7 +8419,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8969,7 +8959,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -10571,7 +10560,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
||||
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -14356,7 +14344,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14367,7 +14354,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16604,7 +16590,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16828,8 +16813,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16837,7 +16821,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -17010,7 +16993,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17218,7 +17200,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17332,7 +17313,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17345,7 +17325,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -18050,7 +18029,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -18345,7 +18323,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -142,6 +142,13 @@ export function renderCoreMandates(options?: CoreMandatesOptions): string {
|
||||
return `
|
||||
# Core Mandates
|
||||
|
||||
- **Context Efficiency:**
|
||||
- Avoid wasting context window by scoping your ${GREP_TOOL_NAME} searches to just enough information to definitively answer the question.
|
||||
- Some examples:
|
||||
- Use total_max_matches to limit the ${GREP_TOOL_NAME} response size when you know that you only need a limited number. e.g.: "find me an example usage of path.resolve()" can use limit=5 to get 5 lines with locations to look instead of spending hundreds of tokens of context.
|
||||
- Use max_matches_per_file when searching broadly to limit the impact of each matching file. e.g.: "which files use NodeJS worker threads" only requires one match per file to answer.
|
||||
- Use total_max_matches to limit the response size when you know that you only need a limited number. e.g.: "find me an example usage of path.resolve()" can use limit=5 to get 5 lines with locations to look instead of spending hundreds of tokens of context.
|
||||
|
||||
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
|
||||
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
|
||||
|
||||
@@ -310,6 +310,31 @@ describe('GrepTool', () => {
|
||||
expect(result.error?.type).toBe(ToolErrorType.GREP_EXECUTION_ERROR);
|
||||
vi.mocked(glob.globStream).mockReset();
|
||||
}, 30000);
|
||||
|
||||
it('should limit matches per file when max_matches_per_file is set', async () => {
|
||||
// fileA.txt has 2 matches for "world"
|
||||
// sub/fileC.txt has 1 match for "world"
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'world',
|
||||
max_matches_per_file: 1,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should find 1 match in fileA.txt (instead of 2)
|
||||
// And 1 match in sub/fileC.txt
|
||||
// Total 2 matches (was 3)
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
expect(result.llmContent).toContain('File: fileA.txt');
|
||||
|
||||
// Count occurrences of match lines in the output
|
||||
// Matches lines start with L<number>:
|
||||
const content =
|
||||
typeof result.llmContent === 'string' ? result.llmContent : '';
|
||||
const matches = content.match(/^L\d+:.*world/gm);
|
||||
expect(matches?.length).toBe(2);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('multi-directory workspace', () => {
|
||||
|
||||
@@ -46,6 +46,16 @@ export interface GrepToolParams {
|
||||
* File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")
|
||||
*/
|
||||
include?: string;
|
||||
|
||||
/**
|
||||
* Optional: Maximum number of matches to return per file.
|
||||
*/
|
||||
max_matches_per_file?: number;
|
||||
|
||||
/**
|
||||
* Optional: Maximum number of total matches to return.
|
||||
*/
|
||||
total_max_matches?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +194,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
// Collect matches from all search directories
|
||||
let allMatches: GrepMatch[] = [];
|
||||
const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES;
|
||||
const totalMaxMatches =
|
||||
this.params.total_max_matches ?? DEFAULT_TOTAL_MAX_MATCHES;
|
||||
|
||||
// Create a timeout controller to prevent indefinitely hanging searches
|
||||
const timeoutController = new AbortController();
|
||||
@@ -210,6 +221,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
path: searchDir,
|
||||
include: this.params.include,
|
||||
maxMatches: remainingLimit,
|
||||
maxMatchesPerFile: this.params.max_matches_per_file,
|
||||
signal: timeoutController.signal,
|
||||
});
|
||||
|
||||
@@ -338,9 +350,16 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
path: string; // Expects absolute path
|
||||
include?: string;
|
||||
maxMatches: number;
|
||||
maxMatchesPerFile?: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<GrepMatch[]> {
|
||||
const { pattern, path: absolutePath, include, maxMatches } = options;
|
||||
const {
|
||||
pattern,
|
||||
path: absolutePath,
|
||||
include,
|
||||
maxMatches,
|
||||
maxMatchesPerFile,
|
||||
} = options;
|
||||
let strategyUsed = 'none';
|
||||
|
||||
try {
|
||||
@@ -363,6 +382,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
try {
|
||||
// Normal execution without filter
|
||||
const generator = execStreaming('git', gitArgs, {
|
||||
cwd: absolutePath,
|
||||
signal: options.signal,
|
||||
@@ -370,9 +390,19 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
});
|
||||
|
||||
const results: GrepMatch[] = [];
|
||||
const matchesPerFile = new Map<string, number>();
|
||||
|
||||
for await (const line of generator) {
|
||||
const match = this.parseGrepLine(line, absolutePath);
|
||||
if (match) {
|
||||
if (maxMatchesPerFile) {
|
||||
const count = matchesPerFile.get(match.filePath) || 0;
|
||||
if (count >= maxMatchesPerFile) {
|
||||
continue;
|
||||
}
|
||||
matchesPerFile.set(match.filePath, count + 1);
|
||||
}
|
||||
|
||||
results.push(match);
|
||||
if (results.length >= maxMatches) {
|
||||
break;
|
||||
@@ -420,9 +450,16 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
})
|
||||
.filter((dir): dir is string => !!dir);
|
||||
commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
|
||||
|
||||
// Normal system grep execution
|
||||
if (include) {
|
||||
grepArgs.push(`--include=${include}`);
|
||||
}
|
||||
|
||||
if (maxMatchesPerFile) {
|
||||
grepArgs.push(`-m`, maxMatchesPerFile.toString());
|
||||
}
|
||||
|
||||
grepArgs.push(pattern);
|
||||
grepArgs.push('.');
|
||||
|
||||
@@ -493,7 +530,9 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
try {
|
||||
const content = await fsPromises.readFile(fileAbsolutePath, 'utf8');
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
let fileMatchCount = 0;
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
if (regex.test(line)) {
|
||||
@@ -504,7 +543,10 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
lineNumber: index + 1,
|
||||
line,
|
||||
});
|
||||
fileMatchCount++;
|
||||
if (allMatches.length >= maxMatches) break;
|
||||
if (maxMatchesPerFile && fileMatchCount >= maxMatchesPerFile)
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (readError: unknown) {
|
||||
@@ -576,7 +618,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
super(
|
||||
GrepTool.Name,
|
||||
'SearchText',
|
||||
'Searches for a regular expression pattern within file contents. Max 100 matches.',
|
||||
'Searches for a regular expression pattern within file contents.',
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
@@ -593,6 +635,18 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
description: `Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).`,
|
||||
type: 'string',
|
||||
},
|
||||
max_matches_per_file: {
|
||||
description:
|
||||
'Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.',
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
total_max_matches: {
|
||||
description:
|
||||
'Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.',
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
type: 'object',
|
||||
|
||||
@@ -1725,6 +1725,36 @@ describe('RipGrepTool', () => {
|
||||
// Note: Ripgrep JSON output for context lines doesn't include line numbers for context lines directly
|
||||
// The current parsing only extracts the matched line, so we only assert on that.
|
||||
});
|
||||
|
||||
it('should handle max_matches_per_file parameter', async () => {
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
outputData:
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileA.txt' },
|
||||
line_number: 1,
|
||||
lines: { text: 'match 1\n' },
|
||||
},
|
||||
}) + '\n',
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const params: RipGrepToolParams = {
|
||||
pattern: 'match',
|
||||
max_matches_per_file: 5,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
await invocation.execute(abortSignal);
|
||||
|
||||
expect(mockSpawn).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.arrayContaining(['--max-count', '5']),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
|
||||
@@ -131,6 +131,16 @@ export interface RipGrepToolParams {
|
||||
* If true, does not respect .gitignore or default ignores (like build/dist).
|
||||
*/
|
||||
no_ignore?: boolean;
|
||||
|
||||
/**
|
||||
* Optional: Maximum number of matches to return per file.
|
||||
*/
|
||||
max_matches_per_file?: number;
|
||||
|
||||
/**
|
||||
* Optional: Maximum number of total matches to return.
|
||||
*/
|
||||
total_max_matches?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +214,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
const searchDirDisplay = pathParam;
|
||||
|
||||
const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES;
|
||||
const totalMaxMatches =
|
||||
this.params.total_max_matches ?? DEFAULT_TOTAL_MAX_MATCHES;
|
||||
if (this.config.getDebugMode()) {
|
||||
debugLogger.log(`[GrepTool] Total result limit: ${totalMaxMatches}`);
|
||||
}
|
||||
@@ -236,6 +247,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
before: this.params.before,
|
||||
no_ignore: this.params.no_ignore,
|
||||
maxMatches: totalMaxMatches,
|
||||
maxMatchesPerFile: this.params.max_matches_per_file,
|
||||
signal: timeoutController.signal,
|
||||
});
|
||||
} finally {
|
||||
@@ -320,6 +332,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
before?: number;
|
||||
no_ignore?: boolean;
|
||||
maxMatches: number;
|
||||
maxMatchesPerFile?: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<GrepMatch[]> {
|
||||
const {
|
||||
@@ -333,8 +346,11 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
before,
|
||||
no_ignore,
|
||||
maxMatches,
|
||||
maxMatchesPerFile,
|
||||
} = options;
|
||||
|
||||
const rgPath = await ensureRgPath();
|
||||
|
||||
const rgArgs = ['--json'];
|
||||
|
||||
if (!case_sensitive) {
|
||||
@@ -361,6 +377,10 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
rgArgs.push('--no-ignore');
|
||||
}
|
||||
|
||||
if (maxMatchesPerFile) {
|
||||
rgArgs.push('--max-count', maxMatchesPerFile.toString());
|
||||
}
|
||||
|
||||
if (include) {
|
||||
rgArgs.push('--glob', include);
|
||||
}
|
||||
@@ -380,20 +400,18 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
rgArgs.push('--glob', `!${exclude}`);
|
||||
});
|
||||
|
||||
// Add .geminiignore and custom ignore files support (if provided/mandated)
|
||||
// (ripgrep natively handles .gitignore)
|
||||
const geminiIgnorePaths = this.fileDiscoveryService.getIgnoreFilePaths();
|
||||
for (const ignorePath of geminiIgnorePaths) {
|
||||
rgArgs.push('--ignore-file', ignorePath);
|
||||
}
|
||||
}
|
||||
|
||||
rgArgs.push('--threads', '4');
|
||||
rgArgs.push(absolutePath);
|
||||
|
||||
// Add threads
|
||||
rgArgs.push('--threads', '4');
|
||||
|
||||
const results: GrepMatch[] = [];
|
||||
try {
|
||||
const rgPath = await ensureRgPath();
|
||||
const generator = execStreaming(rgPath, rgArgs, {
|
||||
signal: options.signal,
|
||||
allowedExitCodes: [0, 1],
|
||||
@@ -422,7 +440,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
): GrepMatch | null {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.type === 'match') {
|
||||
if (json.type === 'match' || json.type === 'context') {
|
||||
const match = json.data;
|
||||
// Defensive check: ensure text properties exist (skips binary/invalid encoding)
|
||||
if (match.path?.text && match.lines?.text) {
|
||||
@@ -499,7 +517,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
super(
|
||||
RipGrepTool.Name,
|
||||
'SearchText',
|
||||
'Searches for a regular expression pattern within file contents. Max 100 matches.',
|
||||
'Searches for a regular expression pattern within file contents. Utilize parameters like max_matches_per_file to avoid one file providing excessive results. Defaults to 100 matches.',
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
@@ -548,6 +566,18 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
'If true, searches all files including those usually ignored (like in .gitignore, build/, dist/, etc). Defaults to false if omitted.',
|
||||
type: 'boolean',
|
||||
},
|
||||
max_matches_per_file: {
|
||||
description:
|
||||
'Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.',
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
total_max_matches: {
|
||||
description:
|
||||
'Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 2000 if omitted.',
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
type: 'object',
|
||||
|
||||
Reference in New Issue
Block a user