basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import {8 describe,9 it,10 expect,11 beforeEach,12 afterEach,13 vi,14 type Mock,15} from 'vitest';16import type { RipGrepToolParams } from './ripGrep.js';17import { _resetRipGrepCachesForTest, RipGrepTool } from './ripGrep.js';18import path from 'node:path';19import fsSync from 'node:fs';20import fs from 'node:fs/promises';21import os, { EOL } from 'node:os';22import type { Config } from '../config/config.js';23import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';24import { spawn } from 'node:child_process';25import { runRipgrep } from '../utils/ripgrepUtils.js';26import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';27import { FileReadCache } from '../services/fileReadCache.js';28 29// Mock ripgrepUtils30vi.mock('../utils/ripgrepUtils.js', () => ({31 runRipgrep: vi.fn(),32}));33 34// Mock child_process for ripgrep calls35vi.mock('child_process', () => ({36 spawn: vi.fn(),37}));38 39const mockSpawn = vi.mocked(spawn);40 41describe('RipGrepTool', () => {42 let tempRootDir: string;43 let grepTool: RipGrepTool;44 let fileExclusionsMock: { getGlobExcludes: () => string[] };45 let fileReadCache: FileReadCache;46 const abortSignal = new AbortController().signal;47 const sep = '\x1f';48 49 const mockConfig = {50 getTargetDir: () => tempRootDir,51 getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),52 getWorkingDir: () => tempRootDir,53 getDebugMode: () => false,54 getUseBuiltinRipgrep: () => true,55 getTruncateToolOutputThreshold: () => 25000,56 getTruncateToolOutputLines: () => 1000,57 } as unknown as Config;58 59 beforeEach(async () => {60 vi.clearAllMocks();61 mockSpawn.mockReset();62 _resetRipGrepCachesForTest();63 Object.assign(mockConfig, {64 getTruncateToolOutputThreshold: () => 25000,65 });66 tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));67 fileExclusionsMock = {68 getGlobExcludes: vi.fn().mockReturnValue([]),69 };70 fileReadCache = new FileReadCache();71 Object.assign(mockConfig, {72 getFileExclusions: () => fileExclusionsMock,73 getFileFilteringOptions: () => DEFAULT_FILE_FILTERING_OPTIONS,74 getFileReadCache: () => fileReadCache,75 getFileReadCacheDisabled: () => false,76 });77 grepTool = new RipGrepTool(mockConfig);78 79 // Create some test files and directories80 await fs.writeFile(81 path.join(tempRootDir, 'fileA.txt'),82 'hello world\nsecond line with world',83 );84 await fs.writeFile(85 path.join(tempRootDir, 'fileB.js'),86 'const foo = "bar";\nfunction baz() { return "hello"; }',87 );88 await fs.mkdir(path.join(tempRootDir, 'sub'));89 await fs.writeFile(90 path.join(tempRootDir, 'sub', 'fileC.txt'),91 'another world in sub dir',92 );93 await fs.writeFile(94 path.join(tempRootDir, 'sub', 'fileD.md'),95 '# Markdown file\nThis is a test.',96 );97 });98 99 afterEach(async () => {100 await fs.rm(tempRootDir, { recursive: true, force: true });101 });102 103 describe('validateToolParams', () => {104 it('should return null for valid params (pattern only)', () => {105 const params: RipGrepToolParams = { pattern: 'hello' };106 expect(grepTool.validateToolParams(params)).toBeNull();107 });108 109 it('should return null for valid params (pattern and path)', () => {110 const params: RipGrepToolParams = { pattern: 'hello', path: '.' };111 expect(grepTool.validateToolParams(params)).toBeNull();112 });113 114 it('should return null for valid params (pattern, path, and glob)', () => {115 const params: RipGrepToolParams = {116 pattern: 'hello',117 path: '.',118 glob: '*.txt',119 };120 expect(grepTool.validateToolParams(params)).toBeNull();121 });122 123 it('should return null for a positive integer limit', () => {124 const params: RipGrepToolParams = { pattern: 'hello', limit: 2 };125 expect(grepTool.validateToolParams(params)).toBeNull();126 });127 128 it.each([129 [0, 'params/limit must be >= 1'],130 [-1, 'params/limit must be >= 1'],131 [1.5, 'params/limit must be integer'],132 ])('should return error for invalid limit %s', (limit, expectedError) => {133 const params: RipGrepToolParams = { pattern: 'hello', limit };134 expect(grepTool.validateToolParams(params)).toBe(expectedError);135 });136 137 it('should return error if pattern is missing', () => {138 const params = { path: '.' } as unknown as RipGrepToolParams;139 expect(grepTool.validateToolParams(params)).toBe(140 `params must have required property 'pattern'`,141 );142 });143 144 it('should surface an error for invalid regex pattern', () => {145 const params: RipGrepToolParams = { pattern: '[[' };146 expect(grepTool.validateToolParams(params)).toContain(147 'Invalid regular expression pattern: [[',148 );149 });150 151 it('should return error if path does not exist', () => {152 const params: RipGrepToolParams = {153 pattern: 'hello',154 path: 'nonexistent',155 };156 // Check for the core error message, as the full path might vary157 expect(grepTool.validateToolParams(params)).toContain(158 'Path does not exist:',159 );160 expect(grepTool.validateToolParams(params)).toContain('nonexistent');161 });162 163 it('should allow path to be a file', () => {164 const filePath = path.join(tempRootDir, 'fileA.txt');165 const params: RipGrepToolParams = { pattern: 'hello', path: filePath };166 expect(grepTool.validateToolParams(params)).toBeNull();167 });168 169 it.skipIf(process.platform === 'win32')(170 'should unescape shell-escaped path',171 async () => {172 // Create a directory with a space so the unescaped path exists173 const dirWithSpace = path.join(tempRootDir, 'sub dir');174 await fs.mkdir(dirWithSpace);175 const params: RipGrepToolParams = {176 pattern: 'hello',177 path: path.join(tempRootDir, 'sub\\ dir'),178 };179 expect(grepTool.validateToolParams(params)).toBeNull();180 expect(params.path).toBe(dirWithSpace);181 },182 );183 });184 185 describe('execute', () => {186 it('should find matches for a simple pattern in all files', async () => {187 (runRipgrep as Mock).mockResolvedValue({188 stdout: `fileA.txt${sep}1${sep}hello world${EOL}fileA.txt${sep}2${sep}second line with world${EOL}sub/fileC.txt${sep}1${sep}another world in sub dir${EOL}`,189 truncated: false,190 error: undefined,191 });192 193 const params: RipGrepToolParams = { pattern: 'world' };194 const invocation = grepTool.build(params);195 const result = await invocation.execute(abortSignal);196 expect(result.llmContent).toContain(197 'Found 3 matches for pattern "world" in the workspace directory',198 );199 expect(result.llmContent).toContain('fileA.txt:1:hello world');200 expect(result.llmContent).toContain('fileA.txt:2:second line with world');201 expect(result.llmContent).toContain(202 'sub/fileC.txt:1:another world in sub dir',203 );204 expect(result.returnDisplay).toBe('Found 3 matches');205 expect(result.resultFilePaths).toEqual([206 path.join(tempRootDir, 'fileA.txt'),207 path.join(tempRootDir, 'sub/fileC.txt'),208 ]);209 210 const fileAStats = await fs.stat(path.join(tempRootDir, 'fileA.txt'));211 const fileCStats = await fs.stat(path.join(tempRootDir, 'sub/fileC.txt'));212 const fileARead = fileReadCache.check(fileAStats);213 const fileCRead = fileReadCache.check(fileCStats);214 expect(fileARead.state).toBe('fresh');215 expect(fileCRead.state).toBe('fresh');216 if (fileARead.state === 'fresh') {217 expect(fileARead.entry.lastReadWasFull).toBe(false);218 expect(fileARead.entry.lastReadCacheable).toBe(true);219 }220 if (fileCRead.state === 'fresh') {221 expect(fileCRead.entry.lastReadWasFull).toBe(false);222 expect(fileCRead.entry.lastReadCacheable).toBe(true);223 }224 });225 226 it('should treat summary-only JSON output as no matches', async () => {227 (runRipgrep as Mock).mockResolvedValue({228 stdout: `${JSON.stringify({ type: 'summary', data: { stats: { matches: 0 } } })}${EOL}`,229 truncated: false,230 error: undefined,231 });232 233 const invocation = grepTool.build({ pattern: 'missing' });234 const result = await invocation.execute(abortSignal);235 236 expect(result.llmContent).toBe(237 'No matches found for pattern "missing" in the workspace directory.',238 );239 expect(result.returnDisplay).toBe('No matches found');240 });241 242 it('parses JSON match events and records result paths', async () => {243 (runRipgrep as Mock).mockResolvedValue({244 stdout: `${JSON.stringify({ type: 'match', data: { path: { text: 'src/foo.ts' }, lines: { text: 'content\n' }, line_number: 5 } })}${EOL}`,245 truncated: false,246 error: undefined,247 });248 249 const invocation = grepTool.build({ pattern: 'content' });250 const result = await invocation.execute(abortSignal);251 252 expect(result.llmContent).toContain('src/foo.ts:5:content');253 expect(result.resultFilePaths).toEqual([254 path.join(tempRootDir, 'src/foo.ts'),255 ]);256 });257 258 it('parses JSON match events with byte-encoded paths', async () => {259 const bytePath = 'src/byte-path.ts';260 (runRipgrep as Mock).mockResolvedValue({261 stdout: `${JSON.stringify({ type: 'match', data: { path: { bytes: Buffer.from(bytePath, 'utf8').toString('base64') }, lines: { text: 'content\n' }, line_number: 3 } })}${EOL}`,262 truncated: false,263 error: undefined,264 });265 266 const invocation = grepTool.build({ pattern: 'content' });267 const result = await invocation.execute(abortSignal);268 269 expect(result.llmContent).toContain('src/byte-path.ts:3:content');270 expect(result.resultFilePaths).toEqual([271 path.join(tempRootDir, bytePath),272 ]);273 });274 275 it('handles JSON match events without a lines field', async () => {276 (runRipgrep as Mock).mockResolvedValue({277 stdout: `${JSON.stringify({ type: 'match', data: { path: { text: 'fileA.txt' }, line_number: 1 } })}${EOL}`,278 truncated: false,279 error: undefined,280 });281 282 const invocation = grepTool.build({ pattern: 'hello' });283 const result = await invocation.execute(abortSignal);284 285 expect(result.llmContent).toContain('fileA.txt:1:');286 expect(result.resultFilePaths).toEqual([287 path.join(tempRootDir, 'fileA.txt'),288 ]);289 });290 291 it('surfaces ripgrep system-level truncation in display metadata', async () => {292 (runRipgrep as Mock).mockResolvedValue({293 stdout: `fileA.txt${sep}1${sep}hello world${EOL}`,294 truncated: true,295 error: undefined,296 });297 298 const invocation = grepTool.build({ pattern: 'hello' });299 const result = await invocation.execute(abortSignal);300 301 expect(result.returnDisplay).toBe('Found 1 match (truncated)');302 expect(result.llmContent).toContain('[0 lines truncated] ...');303 });304 305 it('should preserve absolute result paths reported by ripgrep', async () => {306 const absoluteMatchPath = path.join(307 tempRootDir,308 'packages/core/src/skills/target.ts',309 );310 (runRipgrep as Mock).mockResolvedValue({311 stdout: `${absoluteMatchPath}${sep}1${sep}CORE_HELPER_TARGET_MARKER${EOL}`,312 truncated: false,313 error: undefined,314 });315 316 const params: RipGrepToolParams = {317 pattern: 'CORE_HELPER_TARGET_MARKER',318 glob: '**/*.ts',319 };320 const invocation = grepTool.build(params);321 const result = await invocation.execute(abortSignal);322 323 expect(result.resultFilePaths).toEqual([absoluteMatchPath]);324 });325 326 it('should parse Windows-style absolute result paths reported by ripgrep', async () => {327 const absoluteMatchPath =328 'C:\\repo\\packages\\core\\src\\skills\\target.ts';329 (runRipgrep as Mock).mockResolvedValue({330 stdout: `${absoluteMatchPath}${sep}12${sep}CORE_HELPER_TARGET_MARKER${EOL}`,331 truncated: false,332 error: undefined,333 });334 335 const invocation = grepTool.build({336 pattern: 'CORE_HELPER_TARGET_MARKER',337 glob: '**/*.ts',338 });339 const result = await invocation.execute(abortSignal);340 341 expect(result.resultFilePaths).toEqual([absoluteMatchPath]);342 });343 344 it('includes result paths for partially rendered long file paths', async () => {345 Object.assign(mockConfig, {346 getTruncateToolOutputThreshold: () => 30,347 });348 const longPath = 'packages/core/src/skills/very-long-named-file.ts';349 (runRipgrep as Mock).mockResolvedValue({350 stdout: `${longPath}${sep}1${sep}visible marker${EOL}`,351 truncated: false,352 error: undefined,353 });354 355 const invocation = grepTool.build({ pattern: 'marker', glob: '**/*.ts' });356 const result = await invocation.execute(abortSignal);357 358 expect(result.returnDisplay).toContain('truncated');359 expect(result.llmContent).toContain('packages/core/src/skills/very');360 expect(result.resultFilePaths).toEqual([361 path.join(tempRootDir, longPath),362 ]);363 });364 365 it('only reports result paths for lines reached before character truncation', async () => {366 Object.assign(mockConfig, {367 getTruncateToolOutputThreshold: () => 25,368 });369 const visiblePath = 'a.ts';370 const hiddenPath = 'hidden-file-with-long-name.ts';371 (runRipgrep as Mock).mockResolvedValue({372 stdout: `${visiblePath}${sep}1${sep}visible marker${EOL}${hiddenPath}${sep}1${sep}hidden marker${EOL}`,373 truncated: false,374 error: undefined,375 });376 377 const invocation = grepTool.build({ pattern: 'marker', glob: '**/*.ts' });378 const result = await invocation.execute(abortSignal);379 380 expect(result.returnDisplay).toContain('truncated');381 expect(result.resultFilePaths).toEqual([382 path.join(tempRootDir, visiblePath),383 path.join(tempRootDir, hiddenPath),384 ]);385 });386 387 it('should find matches in a specific path', async () => {388 // Setup specific mock for this test - searching in 'sub' should only return matches from that directory389 (runRipgrep as Mock).mockResolvedValue({390 stdout: `fileC.txt:1:another world in sub dir${EOL}`,391 truncated: false,392 error: undefined,393 });394 395 const params: RipGrepToolParams = { pattern: 'world', path: 'sub' };396 const invocation = grepTool.build(params);397 const result = await invocation.execute(abortSignal);398 expect(result.llmContent).toContain(399 'Found 1 match for pattern "world" in path "sub"',400 );401 expect(result.llmContent).toContain(402 'fileC.txt:1:another world in sub dir',403 );404 expect(result.returnDisplay).toBe('Found 1 match');405 });406 407 it('should use target directory when path is not provided', async () => {408 (runRipgrep as Mock).mockResolvedValue({409 stdout: `fileA.txt:1:hello world${EOL}`,410 truncated: false,411 error: undefined,412 });413 414 const params: RipGrepToolParams = { pattern: 'world' };415 const invocation = grepTool.build(params);416 const result = await invocation.execute(abortSignal);417 expect(result.llmContent).toContain(418 'Found 1 match for pattern "world" in the workspace directory',419 );420 });421 422 it('should find matches with a glob filter', async () => {423 // Setup specific mock for this test424 (runRipgrep as Mock).mockResolvedValue({425 stdout: `fileB.js:2:function baz() { return "hello"; }${EOL}`,426 truncated: false,427 error: undefined,428 });429 430 const params: RipGrepToolParams = { pattern: 'hello', glob: '*.js' };431 const invocation = grepTool.build(params);432 const result = await invocation.execute(abortSignal);433 expect(result.llmContent).toContain(434 'Found 1 match for pattern "hello" in the workspace directory (filter: "*.js"):',435 );436 expect(result.llmContent).toContain(437 'fileB.js:2:function baz() { return "hello"; }',438 );439 expect(result.returnDisplay).toBe('Found 1 match');440 });441 442 it('should find matches with a glob filter and path', async () => {443 await fs.writeFile(444 path.join(tempRootDir, 'sub', 'another.js'),445 'const greeting = "hello";',446 );447 448 // Setup specific mock for this test - searching for 'hello' in 'sub' with '*.js' filter449 (runRipgrep as Mock).mockResolvedValue({450 stdout: `another.js:1:const greeting = "hello";${EOL}`,451 truncated: false,452 error: undefined,453 });454 455 const params: RipGrepToolParams = {456 pattern: 'hello',457 path: 'sub',458 glob: '*.js',459 };460 const invocation = grepTool.build(params);461 const result = await invocation.execute(abortSignal);462 expect(result.llmContent).toContain(463 'Found 1 match for pattern "hello" in path "sub" (filter: "*.js")',464 );465 expect(result.llmContent).toContain(466 'another.js:1:const greeting = "hello";',467 );468 expect(result.returnDisplay).toBe('Found 1 match');469 });470 471 it('should pass .qwenignore to ripgrep when respected', async () => {472 await fs.writeFile(473 path.join(tempRootDir, '.qwenignore'),474 'ignored.txt\n',475 );476 (runRipgrep as Mock).mockResolvedValue({477 stdout: '',478 truncated: false,479 error: undefined,480 });481 482 const params: RipGrepToolParams = { pattern: 'secret' };483 const invocation = grepTool.build(params);484 const result = await invocation.execute(abortSignal);485 expect(result.llmContent).toContain(486 'No matches found for pattern "secret" in the workspace directory.',487 );488 expect(result.returnDisplay).toBe('No matches found');489 });490 491 it('should include .qwenignore matches when disabled in config', async () => {492 await fs.writeFile(path.join(tempRootDir, '.qwenignore'), 'kept.txt\n');493 await fs.writeFile(path.join(tempRootDir, 'kept.txt'), 'keep me');494 Object.assign(mockConfig, {495 getFileFilteringOptions: () => ({496 respectGitIgnore: true,497 respectQwenIgnore: false,498 }),499 });500 501 (runRipgrep as Mock).mockResolvedValue({502 stdout: `kept.txt:1:keep me${EOL}`,503 truncated: false,504 error: undefined,505 });506 507 const params: RipGrepToolParams = { pattern: 'keep' };508 const invocation = grepTool.build(params);509 const result = await invocation.execute(abortSignal);510 expect(result.llmContent).toContain(511 'Found 1 match for pattern "keep" in the workspace directory:',512 );513 expect(result.llmContent).toContain('kept.txt:1:keep me');514 expect(result.returnDisplay).toBe('Found 1 match');515 });516 517 it('should disable gitignore when configured', async () => {518 Object.assign(mockConfig, {519 getFileFilteringOptions: () => ({520 respectGitIgnore: false,521 respectQwenIgnore: true,522 }),523 });524 525 (runRipgrep as Mock).mockResolvedValue({526 stdout: '',527 truncated: false,528 error: undefined,529 });530 531 const params: RipGrepToolParams = { pattern: 'ignored' };532 const invocation = grepTool.build(params);533 await invocation.execute(abortSignal);534 });535 536 it('should truncate llm content when exceeding maximum length', async () => {537 const longMatch = 'fileA.txt:1:' + 'a'.repeat(30_000);538 539 (runRipgrep as Mock).mockResolvedValue({540 stdout: `${longMatch}${EOL}`,541 truncated: false,542 error: undefined,543 });544 545 const params: RipGrepToolParams = { pattern: 'a+' };546 const invocation = grepTool.build(params);547 const result = await invocation.execute(abortSignal);548 549 expect(String(result.llmContent).length).toBeLessThanOrEqual(26_000);550 expect(result.llmContent).toMatch(/\[\d+ lines? truncated\] \.\.\./);551 expect(result.returnDisplay).toContain('truncated');552 });553 554 it('should return "No matches found" when pattern does not exist', async () => {555 // Setup specific mock for no matches556 (runRipgrep as Mock).mockResolvedValue({557 stdout: '',558 truncated: false,559 error: undefined,560 });561 562 const params: RipGrepToolParams = { pattern: 'nonexistentpattern' };563 const invocation = grepTool.build(params);564 const result = await invocation.execute(abortSignal);565 expect(result.llmContent).toContain(566 'No matches found for pattern "nonexistentpattern" in the workspace directory.',567 );568 expect(result.returnDisplay).toBe('No matches found');569 });570 571 it('should throw validation error for invalid regex pattern', async () => {572 const params: RipGrepToolParams = { pattern: '[[' };573 expect(() => grepTool.build(params)).toThrow(574 'Invalid regular expression pattern: [[',575 );576 });577 578 it('should handle regex special characters correctly', async () => {579 // Setup specific mock for this test - regex pattern 'foo.*bar' should match 'const foo = "bar";'580 (runRipgrep as Mock).mockResolvedValue({581 stdout: `fileB.js:1:const foo = "bar";${EOL}`,582 truncated: false,583 error: undefined,584 });585 586 const params: RipGrepToolParams = { pattern: 'foo.*bar' }; // Matches 'const foo = "bar";'587 const invocation = grepTool.build(params);588 const result = await invocation.execute(abortSignal);589 expect(result.llmContent).toContain(590 'Found 1 match for pattern "foo.*bar" in the workspace directory:',591 );592 expect(result.llmContent).toContain('fileB.js:1:const foo = "bar";');593 });594 595 it('should be case-insensitive by default (JS fallback)', async () => {596 // Setup specific mock for this test - case insensitive search for 'HELLO'597 (runRipgrep as Mock).mockResolvedValue({598 stdout: `fileA.txt:1:hello world${EOL}fileB.js:2:function baz() { return "hello"; }${EOL}`,599 truncated: false,600 error: undefined,601 });602 603 const params: RipGrepToolParams = { pattern: 'HELLO' };604 const invocation = grepTool.build(params);605 const result = await invocation.execute(abortSignal);606 expect(result.llmContent).toContain(607 'Found 2 matches for pattern "HELLO" in the workspace directory:',608 );609 expect(result.llmContent).toContain('fileA.txt:1:hello world');610 expect(result.llmContent).toContain(611 'fileB.js:2:function baz() { return "hello"; }',612 );613 });614 615 it('should throw an error if params are invalid', async () => {616 const params = { path: '.' } as unknown as RipGrepToolParams; // Invalid: pattern missing617 expect(() => grepTool.build(params)).toThrow(618 /params must have required property 'pattern'/,619 );620 });621 622 it('should search within a single file when path is a file', async () => {623 (runRipgrep as Mock).mockResolvedValue({624 stdout: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`,625 truncated: false,626 error: undefined,627 });628 629 const params: RipGrepToolParams = {630 pattern: 'world',631 path: path.join(tempRootDir, 'fileA.txt'),632 };633 const invocation = grepTool.build(params);634 const result = await invocation.execute(abortSignal);635 expect(result.llmContent).toContain('Found 2 matches');636 expect(result.llmContent).toContain('fileA.txt:1:hello world');637 expect(result.llmContent).toContain('fileA.txt:2:second line with world');638 expect(result.returnDisplay).toBe('Found 2 matches');639 });640 641 it('should throw an error if ripgrep is not available', async () => {642 (runRipgrep as Mock).mockResolvedValue({643 stdout: '',644 truncated: false,645 error: new Error('ripgrep binary not found.'),646 });647 648 const params: RipGrepToolParams = { pattern: 'world' };649 const invocation = grepTool.build(params);650 651 expect(await invocation.execute(abortSignal)).toStrictEqual({652 llmContent:653 'Error during grep search operation: ripgrep binary not found.',654 returnDisplay: 'Error: ripgrep binary not found.',655 });656 });657 658 it('should pass useBuiltinRipgrep setting to ripgrep execution', async () => {659 const systemOnlyConfig = {660 ...mockConfig,661 getUseBuiltinRipgrep: () => false,662 } as unknown as Config;663 const systemOnlyGrepTool = new RipGrepTool(systemOnlyConfig);664 665 (runRipgrep as Mock).mockResolvedValue({666 stdout: `fileA.txt${sep}1${sep}hello world${EOL}`,667 truncated: false,668 error: undefined,669 });670 671 const params: RipGrepToolParams = { pattern: 'hello' };672 const invocation = systemOnlyGrepTool.build(params);673 await invocation.execute(abortSignal);674 675 expect(runRipgrep).toHaveBeenCalledWith(676 expect.any(Array),677 abortSignal,678 false,679 );680 });681 });682 683 describe('multi-directory workspace', () => {684 it('should search across all workspace directories when no path is specified', async () => {685 const secondDir = await fs.mkdtemp(686 path.join(os.tmpdir(), 'grep-tool-second-'),687 );688 await fs.writeFile(689 path.join(secondDir, 'extra.txt'),690 'hello from second dir',691 );692 693 const multiDirConfig = {694 ...mockConfig,695 getWorkspaceContext: () =>696 createMockWorkspaceContext(tempRootDir, [secondDir]),697 } as unknown as Config;698 699 const multiDirGrepTool = new RipGrepTool(multiDirConfig);700 701 (runRipgrep as Mock).mockResolvedValue({702 stdout: `fileA.txt${sep}1${sep}hello world${EOL}${secondDir}${path.sep}extra.txt${sep}1${sep}hello from second dir${EOL}`,703 truncated: false,704 error: undefined,705 });706 707 const params: RipGrepToolParams = { pattern: 'hello' };708 const invocation = multiDirGrepTool.build(params);709 const result = await invocation.execute(abortSignal);710 711 expect(result.llmContent).toContain('across 2 workspace directories');712 expect(result.llmContent).toContain('Found 2 matches');713 expect(result.resultFilePaths).toEqual([714 path.join(tempRootDir, 'fileA.txt'),715 path.join(secondDir, 'extra.txt'),716 ]);717 718 // Verify both paths were passed to runRipgrep719 expect(runRipgrep).toHaveBeenCalledWith(720 expect.arrayContaining([721 '--json',722 '--no-messages',723 tempRootDir,724 secondDir,725 ]),726 expect.anything(),727 true,728 );729 730 await fs.rm(secondDir, { recursive: true, force: true });731 });732 733 it('should search only specified path when path is given (ignoring multi-dir)', async () => {734 const secondDir = await fs.mkdtemp(735 path.join(os.tmpdir(), 'grep-tool-second-'),736 );737 await fs.writeFile(path.join(secondDir, 'other.txt'), 'other content');738 739 const multiDirConfig = {740 ...mockConfig,741 getWorkspaceContext: () =>742 createMockWorkspaceContext(tempRootDir, [secondDir]),743 } as unknown as Config;744 745 const multiDirGrepTool = new RipGrepTool(multiDirConfig);746 747 (runRipgrep as Mock).mockResolvedValue({748 stdout: `fileC.txt:1:another world in sub dir${EOL}`,749 truncated: false,750 error: undefined,751 });752 753 const params: RipGrepToolParams = { pattern: 'world', path: 'sub' };754 const invocation = multiDirGrepTool.build(params);755 const result = await invocation.execute(abortSignal);756 757 expect(result.llmContent).toContain('in path "sub"');758 expect(result.llmContent).not.toContain('across');759 760 await fs.rm(secondDir, { recursive: true, force: true });761 });762 763 it('should load .qwenignore from each workspace directory', async () => {764 const secondDir = await fs.mkdtemp(765 path.join(os.tmpdir(), 'grep-tool-second-'),766 );767 await fs.writeFile(path.join(secondDir, '.qwenignore'), 'ignored.txt\n');768 await fs.writeFile(769 path.join(tempRootDir, '.qwenignore'),770 'other-ignored.txt\n',771 );772 773 const multiDirConfig = {774 ...mockConfig,775 getWorkspaceContext: () =>776 createMockWorkspaceContext(tempRootDir, [secondDir]),777 } as unknown as Config;778 779 const multiDirGrepTool = new RipGrepTool(multiDirConfig);780 781 (runRipgrep as Mock).mockResolvedValue({782 stdout: '',783 truncated: false,784 error: undefined,785 });786 787 const params: RipGrepToolParams = { pattern: 'test' };788 const invocation = multiDirGrepTool.build(params);789 await invocation.execute(abortSignal);790 791 // Verify both .qwenignore files were passed792 const rgArgs = (runRipgrep as Mock).mock.calls[0][0] as string[];793 const ignoreFileArgs = rgArgs.filter(794 (a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',795 );796 expect(ignoreFileArgs).toContain(path.join(tempRootDir, '.qwenignore'));797 expect(ignoreFileArgs).toContain(path.join(secondDir, '.qwenignore'));798 799 await fs.rm(secondDir, { recursive: true, force: true });800 });801 802 it('should pass .agentignore and .aiignore to ripgrep when respected', async () => {803 await fs.writeFile(804 path.join(tempRootDir, '.agentignore'),805 'agent-secret.txt\n',806 );807 await fs.writeFile(808 path.join(tempRootDir, '.aiignore'),809 'ai-secret.txt\n',810 );811 812 (runRipgrep as Mock).mockResolvedValue({813 stdout: '',814 truncated: false,815 error: undefined,816 });817 818 const params: RipGrepToolParams = { pattern: 'secret' };819 const invocation = grepTool.build(params);820 await invocation.execute(abortSignal);821 822 const rgArgs = (runRipgrep as Mock).mock.calls[0][0] as string[];823 const ignoreFileArgs = rgArgs.filter(824 (a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',825 );826 expect(ignoreFileArgs).toContain(path.join(tempRootDir, '.agentignore'));827 expect(ignoreFileArgs).toContain(path.join(tempRootDir, '.aiignore'));828 });829 830 it('should pass non-qwen ignore files unchanged so ripgrep preserves negations', async () => {831 const qwenIgnorePath = path.join(tempRootDir, '.qwenignore');832 const agentIgnorePath = path.join(tempRootDir, '.agentignore');833 834 await fs.writeFile(qwenIgnorePath, '*.env\n');835 await fs.writeFile(836 agentIgnorePath,837 '*.env\n!allowed.env\n\\!literal.txt\n',838 );839 840 (runRipgrep as Mock).mockImplementation(async (rgArgs: string[]) => {841 const ignoreFileArgs = rgArgs.filter(842 (a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',843 );844 expect(ignoreFileArgs).toContain(qwenIgnorePath);845 expect(ignoreFileArgs).toContain(agentIgnorePath);846 expect(ignoreFileArgs.indexOf(agentIgnorePath)).toBeLessThan(847 ignoreFileArgs.indexOf(qwenIgnorePath),848 );849 850 const agentIgnoreContent = await fs.readFile(agentIgnorePath, 'utf8');851 expect(agentIgnoreContent).toContain('!allowed.env');852 853 return {854 stdout: '',855 truncated: false,856 error: undefined,857 };858 });859 860 const invocation = grepTool.build({ pattern: 'API_KEY' });861 await invocation.execute(abortSignal);862 });863 864 it('should preserve negation semantics within the same non-qwen ignore file', async () => {865 await fs.writeFile(866 path.join(tempRootDir, '.agentignore'),867 '*.env\n!allowed.env\n',868 );869 await fs.writeFile(path.join(tempRootDir, 'blocked.env'), 'API_KEY=1');870 await fs.writeFile(path.join(tempRootDir, 'allowed.env'), 'API_KEY=2');871 872 (runRipgrep as Mock).mockResolvedValue({873 stdout: `blocked.env${sep}1${sep}API_KEY=1${EOL}allowed.env${sep}1${sep}API_KEY=2${EOL}`,874 truncated: false,875 error: undefined,876 });877 878 const invocation = grepTool.build({ pattern: 'API_KEY' });879 const result = await invocation.execute(abortSignal);880 881 expect(result.llmContent).toContain('Found 1 match');882 expect(result.llmContent).toContain('allowed.env:1:API_KEY=2');883 expect(result.llmContent).not.toContain('blocked.env');884 expect(result.returnDisplay).toBe('Found 1 match');885 expect(result.resultFilePaths).toEqual([886 path.join(tempRootDir, 'allowed.env'),887 ]);888 });889 890 it('should not let a custom ignore negation expose .qwenignore matches in grep output', async () => {891 const qwenIgnorePath = path.join(tempRootDir, '.qwenignore');892 const agentIgnorePath = path.join(tempRootDir, '.agentignore');893 await fs.writeFile(qwenIgnorePath, '*.env\n');894 await fs.writeFile(agentIgnorePath, '!*.env\n');895 await fs.writeFile(path.join(tempRootDir, 'allowed.env'), 'API_KEY=2');896 897 (runRipgrep as Mock).mockResolvedValue({898 stdout: `allowed.env${sep}1${sep}API_KEY=2${EOL}`,899 truncated: false,900 error: undefined,901 });902 903 const invocation = grepTool.build({ pattern: 'API_KEY' });904 const result = await invocation.execute(abortSignal);905 906 expect(result.llmContent).toContain('No matches found');907 expect(result.returnDisplay).toBe('No matches found');908 909 const rgArgs = (runRipgrep as Mock).mock.calls[0][0] as string[];910 const ignoreFileArgs = rgArgs.filter(911 (a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',912 );913 expect(ignoreFileArgs).toEqual([agentIgnorePath, qwenIgnorePath]);914 });915 916 it('should post-filter matches ignored by another workspace .qwenignore', async () => {917 const secondDir = await fs.mkdtemp(918 path.join(os.tmpdir(), 'grep-tool-second-'),919 );920 await fs.writeFile(path.join(tempRootDir, '.qwenignore'), '*.env\n');921 await fs.writeFile(path.join(secondDir, '.qwenignore'), '!*.env\n');922 await fs.writeFile(path.join(tempRootDir, 'secret.env'), 'API_KEY=1');923 await fs.writeFile(path.join(tempRootDir, 'visible.txt'), 'API_KEY=2');924 925 const multiDirConfig = {926 ...mockConfig,927 getWorkspaceContext: () =>928 createMockWorkspaceContext(tempRootDir, [secondDir]),929 } as unknown as Config;930 const multiDirGrepTool = new RipGrepTool(multiDirConfig);931 932 (runRipgrep as Mock).mockResolvedValue({933 stdout: `secret.env${sep}1${sep}API_KEY=1${EOL}visible.txt${sep}1${sep}API_KEY=2${EOL}`,934 truncated: false,935 error: undefined,936 });937 938 const invocation = multiDirGrepTool.build({ pattern: 'API_KEY' });939 const result = await invocation.execute(abortSignal);940 941 expect(result.llmContent).toContain('Found 1 match');942 expect(result.llmContent).toContain('visible.txt:1:API_KEY=2');943 expect(result.llmContent).not.toContain('secret.env');944 expect(result.returnDisplay).toBe('Found 1 match');945 expect(result.resultFilePaths).toEqual([946 path.join(tempRootDir, 'visible.txt'),947 ]);948 949 await fs.rm(secondDir, { recursive: true, force: true });950 });951 952 it('should preserve negation semantics within the same .qwenignore', async () => {953 await fs.writeFile(954 path.join(tempRootDir, '.qwenignore'),955 '*.env\n!allowed.env\n',956 );957 await fs.writeFile(path.join(tempRootDir, 'blocked.env'), 'API_KEY=1');958 await fs.writeFile(path.join(tempRootDir, 'allowed.env'), 'API_KEY=2');959 960 (runRipgrep as Mock).mockResolvedValue({961 stdout: `blocked.env${sep}1${sep}API_KEY=1${EOL}allowed.env${sep}1${sep}API_KEY=2${EOL}`,962 truncated: false,963 error: undefined,964 });965 966 const invocation = grepTool.build({ pattern: 'API_KEY' });967 const result = await invocation.execute(abortSignal);968 969 expect(result.llmContent).toContain('Found 1 match');970 expect(result.llmContent).toContain('allowed.env:1:API_KEY=2');971 expect(result.llmContent).not.toContain('blocked.env');972 expect(result.returnDisplay).toBe('Found 1 match');973 expect(result.resultFilePaths).toEqual([974 path.join(tempRootDir, 'allowed.env'),975 ]);976 });977 978 it('should post-filter matches unignored by a custom nested .qwenignore', async () => {979 await fs.mkdir(path.join(tempRootDir, 'nested'));980 await fs.writeFile(path.join(tempRootDir, '.qwenignore'), '*.env\n');981 await fs.writeFile(982 path.join(tempRootDir, 'nested', '.qwenignore'),983 '!*.env\n',984 );985 await fs.writeFile(path.join(tempRootDir, 'secret.env'), 'API_KEY=1');986 await fs.writeFile(path.join(tempRootDir, 'visible.txt'), 'API_KEY=2');987 Object.assign(mockConfig, {988 getFileFilteringOptions: () => ({989 respectGitIgnore: true,990 respectQwenIgnore: true,991 customIgnoreFiles: ['nested/.qwenignore'],992 }),993 });994 995 (runRipgrep as Mock).mockResolvedValue({996 stdout: `secret.env${sep}1${sep}API_KEY=1${EOL}visible.txt${sep}1${sep}API_KEY=2${EOL}`,997 truncated: false,998 error: undefined,999 });1000 1001 const invocation = grepTool.build({ pattern: 'API_KEY' });1002 const result = await invocation.execute(abortSignal);1003 1004 expect(result.llmContent).toContain('Found 1 match');1005 expect(result.llmContent).toContain('visible.txt:1:API_KEY=2');1006 expect(result.llmContent).not.toContain('secret.env');1007 expect(result.returnDisplay).toBe('Found 1 match');1008 expect(result.resultFilePaths).toEqual([1009 path.join(tempRootDir, 'visible.txt'),1010 ]);1011 1012 const rgArgs = (runRipgrep as Mock).mock.calls[0][0] as string[];1013 const ignoreFileArgs = rgArgs.filter(1014 (a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',1015 );1016 expect(ignoreFileArgs).toEqual([1017 path.join(tempRootDir, 'nested', '.qwenignore'),1018 path.join(tempRootDir, '.qwenignore'),1019 ]);1020 });1021 1022 it('should pass configured custom ignore files to ripgrep', async () => {1023 await fs.writeFile(1024 path.join(tempRootDir, '.cursorignore'),1025 'cursor-secret.txt\n',1026 );1027 await fs.writeFile(1028 path.join(tempRootDir, '.agentignore'),1029 'agent-secret.txt\n',1030 );1031 Object.assign(mockConfig, {1032 getFileFilteringOptions: () => ({1033 respectGitIgnore: true,1034 respectQwenIgnore: true,1035 customIgnoreFiles: ['.cursorignore'],1036 }),1037 });1038 1039 (runRipgrep as Mock).mockResolvedValue({1040 stdout: '',1041 truncated: false,1042 error: undefined,1043 });1044 1045 const params: RipGrepToolParams = { pattern: 'secret' };1046 const invocation = grepTool.build(params);1047 await invocation.execute(abortSignal);1048 1049 const rgArgs = (runRipgrep as Mock).mock.calls[0][0] as string[];1050 const ignoreFileArgs = rgArgs.filter(1051 (a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',1052 );1053 expect(ignoreFileArgs).toContain(path.join(tempRootDir, '.cursorignore'));1054 expect(ignoreFileArgs).not.toContain(1055 path.join(tempRootDir, '.agentignore'),1056 );1057 });1058 1059 it('should resolve ignore files from the workspace root for subdirectory searches', async () => {1060 await fs.writeFile(1061 path.join(tempRootDir, '.cursorignore'),1062 'cursor-secret.txt\n',1063 );1064 await fs.writeFile(1065 path.join(tempRootDir, 'sub', '.cursorignore'),1066 'sub-secret.txt\n',1067 );1068 Object.assign(mockConfig, {1069 getFileFilteringOptions: () => ({1070 respectGitIgnore: true,1071 respectQwenIgnore: true,1072 customIgnoreFiles: ['.cursorignore'],1073 }),1074 });1075 1076 (runRipgrep as Mock).mockResolvedValue({1077 stdout: '',1078 truncated: false,1079 error: undefined,1080 });1081 1082 const params: RipGrepToolParams = {1083 pattern: 'secret',1084 path: 'sub',1085 };1086 const invocation = grepTool.build(params);1087 await invocation.execute(abortSignal);1088 1089 const rgArgs = (runRipgrep as Mock).mock.calls[0][0] as string[];1090 const ignoreFileArgs = rgArgs.filter(1091 (a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',1092 );1093 expect(ignoreFileArgs).toContain(path.join(tempRootDir, '.cursorignore'));1094 expect(ignoreFileArgs).not.toContain(1095 path.join(tempRootDir, 'sub', '.cursorignore'),1096 );1097 });1098 1099 it('should not load ignore files from relative external search paths', async () => {1100 const testCwd = await fs.mkdtemp(1101 path.join(os.tmpdir(), 'grep-tool-cwd-'),1102 );1103 const outsideDir = path.join(testCwd, 'outside');1104 const originalCwd = process.cwd();1105 1106 try {1107 await fs.mkdir(outsideDir);1108 await fs.writeFile(1109 path.join(outsideDir, '.cursorignore'),1110 'cursor-secret.txt\n',1111 );1112 Object.assign(mockConfig, {1113 getFileFilteringOptions: () => ({1114 respectGitIgnore: true,1115 respectQwenIgnore: true,1116 customIgnoreFiles: ['.cursorignore'],1117 }),1118 });1119 1120 (runRipgrep as Mock).mockResolvedValue({1121 stdout: '',1122 truncated: false,1123 error: undefined,1124 });1125 1126 process.chdir(testCwd);1127 1128 const invocation = grepTool.build({1129 pattern: 'secret',1130 }) as unknown as {1131 performRipgrepSearch(options: {1132 pattern: string;1133 paths: string[];1134 signal: AbortSignal;1135 }): Promise<{ stdout: string; truncated: boolean }>;1136 };1137 await invocation.performRipgrepSearch({1138 pattern: 'secret',1139 paths: ['outside'],1140 signal: abortSignal,1141 });1142 1143 const rgArgs = (runRipgrep as Mock).mock.calls[0][0] as string[];1144 const ignoreFileArgs = rgArgs.filter(1145 (a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',1146 );1147 expect(ignoreFileArgs).toEqual([]);1148 } finally {1149 process.chdir(originalCwd);1150 await fs.rm(testCwd, { recursive: true, force: true });1151 }1152 });1153 1154 it('should cache resolved relative result paths across filtering and result metadata', async () => {1155 const existsSyncSpy = vi.spyOn(fsSync, 'existsSync');1156 const repeatedLine = `fileA.txt${sep}1${sep}hello world`;1157 1158 (runRipgrep as Mock).mockResolvedValue({1159 stdout: `${repeatedLine}${EOL}${repeatedLine}${EOL}${repeatedLine}${EOL}`,1160 truncated: false,1161 error: undefined,1162 });1163 1164 const invocation = grepTool.build({ pattern: 'hello' });1165 await invocation.execute(abortSignal);1166 1167 const fileAPath = path.join(tempRootDir, 'fileA.txt');1168 const fileAProbeCount = existsSyncSpy.mock.calls.filter(1169 ([candidate]) => String(candidate) === fileAPath,1170 ).length;1171 expect(fileAProbeCount).toBe(1);1172 });1173 1174 it('should deduplicate matches from overlapping workspace directories', async () => {1175 // This tests the fix: when ripgrep receives overlapping search paths1176 // (e.g. /parent and /parent/sub), it may report the same file twice.1177 // The deduplication layer must remove duplicates.1178 const subDir = path.join(tempRootDir, 'sub');1179 1180 const multiDirConfig = {1181 ...mockConfig,1182 getWorkspaceContext: () =>1183 createMockWorkspaceContext(tempRootDir, [subDir]),1184 } as unknown as Config;1185 1186 const multiDirGrepTool = new RipGrepTool(multiDirConfig);1187 1188 // Simulate ripgrep returning the same file:line twice (once from each search root)1189 const dupLine = `${path.join(subDir, 'fileC.txt')}${sep}1${sep}hello world`;1190 (runRipgrep as Mock).mockResolvedValue({1191 stdout: `${dupLine}${EOL}${dupLine}${EOL}`,1192 truncated: false,1193 error: undefined,1194 });1195 1196 const params: RipGrepToolParams = { pattern: 'hello' };1197 const invocation = multiDirGrepTool.build(params);1198 const result = await invocation.execute(abortSignal);1199 1200 // Despite two identical lines in the raw output, only 1 match should be reported.