basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import type { GlobToolParams, GlobPath } from './glob.js';8import { GlobTool, sortFileEntries } from './glob.js';9import { partListUnionToString } from '../core/geminiRequest.js';10import path from 'node:path';11import fs from 'node:fs/promises';12import os from 'node:os';13import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';14import { FileDiscoveryService } from '../services/fileDiscoveryService.js';15import type { Config } from '../config/config.js';16import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';17import { ToolErrorType } from './tool-error.js';18import * as glob from 'glob';19import type { Path as GlobResultPath } from 'glob';20 21vi.mock('glob', { spy: true });22 23describe('GlobTool', () => {24 let tempRootDir: string; // This will be the rootDirectory for the GlobTool instance25 let globTool: GlobTool;26 const abortSignal = new AbortController().signal;27 28 // Mock config for testing29 const mockConfig = {30 getFileService: () => new FileDiscoveryService(tempRootDir),31 getFileFilteringRespectGitIgnore: () => true,32 getFileFilteringOptions: () => ({33 respectGitIgnore: true,34 respectQwenIgnore: true,35 }),36 getTargetDir: () => tempRootDir,37 getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),38 getFileExclusions: () => ({39 getGlobExcludes: () => [],40 }),41 getTruncateToolOutputLines: () => 1000,42 } as unknown as Config;43 44 beforeEach(async () => {45 // Create a unique root directory for each test run46 tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-tool-root-'));47 await fs.writeFile(path.join(tempRootDir, '.git'), ''); // Fake git repo48 globTool = new GlobTool(mockConfig);49 50 // Create some test files and directories within this root51 // Top-level files52 await fs.writeFile(path.join(tempRootDir, 'fileA.txt'), 'contentA');53 await fs.writeFile(path.join(tempRootDir, 'FileB.TXT'), 'contentB'); // Different case for testing54 55 // Subdirectory and files within it56 await fs.mkdir(path.join(tempRootDir, 'sub'));57 await fs.writeFile(path.join(tempRootDir, 'sub', 'fileC.md'), 'contentC');58 await fs.writeFile(path.join(tempRootDir, 'sub', 'FileD.MD'), 'contentD'); // Different case59 60 // Deeper subdirectory61 await fs.mkdir(path.join(tempRootDir, 'sub', 'deep'));62 await fs.writeFile(63 path.join(tempRootDir, 'sub', 'deep', 'fileE.log'),64 'contentE',65 );66 67 // Files for mtime sorting test68 await fs.writeFile(path.join(tempRootDir, 'older.sortme'), 'older_content');69 // Ensure a noticeable difference in modification time70 await new Promise((resolve) => setTimeout(resolve, 50));71 await fs.writeFile(path.join(tempRootDir, 'newer.sortme'), 'newer_content');72 73 // For type coercion testing74 await fs.mkdir(path.join(tempRootDir, '123'));75 });76 77 afterEach(async () => {78 // Clean up the temporary root directory79 await fs.rm(tempRootDir, { recursive: true, force: true });80 });81 82 const mockTruncationGlobResults = (prefix: string, count: number) => {83 const baseMtimeMs = Date.now();84 const entries = Array.from(85 { length: count },86 (_, index): GlobResultPath => {87 const fileNumber = index + 1;88 return {89 fullpath: () =>90 path.join(tempRootDir, `${prefix}${fileNumber}.trunctest`),91 mtimeMs: baseMtimeMs + fileNumber,92 } as unknown as GlobResultPath;93 },94 );95 96 vi.mocked(glob.glob).mockResolvedValueOnce(entries);97 };98 99 describe('execute', () => {100 it('should find files matching a simple pattern in the root', async () => {101 const params: GlobToolParams = { pattern: '*.txt' };102 const invocation = globTool.build(params);103 const result = await invocation.execute(abortSignal);104 expect(result.llmContent).toContain('Found 2 file(s)');105 expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));106 expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));107 expect(result.returnDisplay).toBe('Found 2 matching file(s)');108 expect(result.resultFilePaths).toHaveLength(2);109 expect(result.resultFilePaths).toContain(110 path.join(tempRootDir, 'fileA.txt'),111 );112 expect(result.resultFilePaths).toContain(113 path.join(tempRootDir, 'FileB.TXT'),114 );115 });116 117 it('should find files case-insensitively by default (pattern: *.TXT)', async () => {118 const params: GlobToolParams = { pattern: '*.TXT' };119 const invocation = globTool.build(params);120 const result = await invocation.execute(abortSignal);121 expect(result.llmContent).toContain('Found 2 file(s)');122 expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));123 expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));124 });125 126 it('should find files using a pattern that includes a subdirectory', async () => {127 const params: GlobToolParams = { pattern: 'sub/*.md' };128 const invocation = globTool.build(params);129 const result = await invocation.execute(abortSignal);130 expect(result.llmContent).toContain('Found 2 file(s)');131 expect(result.llmContent).toContain(132 path.join(tempRootDir, 'sub', 'fileC.md'),133 );134 expect(result.llmContent).toContain(135 path.join(tempRootDir, 'sub', 'FileD.MD'),136 );137 });138 139 it('should find files in a specified relative path (relative to rootDir)', async () => {140 const params: GlobToolParams = { pattern: '*.md', path: 'sub' };141 const invocation = globTool.build(params);142 const result = await invocation.execute(abortSignal);143 expect(result.llmContent).toContain('Found 2 file(s)');144 expect(result.llmContent).toContain(145 path.join(tempRootDir, 'sub', 'fileC.md'),146 );147 expect(result.llmContent).toContain(148 path.join(tempRootDir, 'sub', 'FileD.MD'),149 );150 });151 152 it('should find files using a deep globstar pattern (e.g., **/*.log)', async () => {153 const params: GlobToolParams = { pattern: '**/*.log' };154 const invocation = globTool.build(params);155 const result = await invocation.execute(abortSignal);156 expect(result.llmContent).toContain('Found 1 file(s)');157 expect(result.llmContent).toContain(158 path.join(tempRootDir, 'sub', 'deep', 'fileE.log'),159 );160 });161 162 it('should return "No files found" message when pattern matches nothing', async () => {163 const params: GlobToolParams = { pattern: '*.nonexistent' };164 const invocation = globTool.build(params);165 const result = await invocation.execute(abortSignal);166 expect(result.llmContent).toContain(167 'No files found matching pattern "*.nonexistent"',168 );169 expect(result.returnDisplay).toBe('No files found');170 });171 172 it('should find files with special characters in the name', async () => {173 await fs.writeFile(path.join(tempRootDir, 'file[1].txt'), 'content');174 const params: GlobToolParams = { pattern: 'file[1].txt' };175 const invocation = globTool.build(params);176 const result = await invocation.execute(abortSignal);177 expect(result.llmContent).toContain('Found 1 file(s)');178 expect(result.llmContent).toContain(179 path.join(tempRootDir, 'file[1].txt'),180 );181 });182 183 it('should find files with special characters like [] and () in the path', async () => {184 const filePath = path.join(185 tempRootDir,186 'src/app/[test]/(dashboard)/testing/components/code.tsx',187 );188 await fs.mkdir(path.dirname(filePath), { recursive: true });189 await fs.writeFile(filePath, 'content');190 191 const params: GlobToolParams = {192 pattern: 'src/app/[test]/(dashboard)/testing/components/code.tsx',193 };194 const invocation = globTool.build(params);195 const result = await invocation.execute(abortSignal);196 expect(result.llmContent).toContain('Found 1 file(s)');197 expect(result.llmContent).toContain(filePath);198 });199 200 it('should correctly sort files by modification time (newest first)', async () => {201 const params: GlobToolParams = { pattern: '*.sortme' };202 const invocation = globTool.build(params);203 const result = await invocation.execute(abortSignal);204 const llmContent = partListUnionToString(result.llmContent);205 206 expect(llmContent).toContain('Found 2 file(s)');207 // Ensure llmContent is a string for TypeScript type checking208 expect(typeof llmContent).toBe('string');209 210 const filesListed = llmContent211 .trim()212 .split(/\r?\n/)213 .slice(2)214 .map((line) => line.trim())215 .filter(Boolean);216 217 expect(filesListed).toHaveLength(2);218 expect(path.resolve(filesListed[0])).toBe(219 path.resolve(tempRootDir, 'newer.sortme'),220 );221 expect(path.resolve(filesListed[1])).toBe(222 path.resolve(tempRootDir, 'older.sortme'),223 );224 });225 226 it('should find files even if workspace path casing differs from glob results (Windows/macOS)', async () => {227 // Only relevant for Windows and macOS228 if (process.platform !== 'win32' && process.platform !== 'darwin') {229 return;230 }231 232 let mismatchedRootDir = tempRootDir;233 234 if (process.platform === 'win32') {235 // 1. Create a path with mismatched casing for the workspace root236 // e.g., if tempRootDir is "C:\Users\...", make it "c:\Users\..."237 const drive = path.parse(tempRootDir).root;238 if (!drive || !drive.match(/^[A-Z]:\\/)) {239 // Skip if we can't determine/manipulate the drive letter easily240 return;241 }242 243 const lowerDrive = drive.toLowerCase();244 mismatchedRootDir = lowerDrive + tempRootDir.substring(drive.length);245 } else {246 // macOS: change the casing of the path247 if (tempRootDir === tempRootDir.toLowerCase()) {248 mismatchedRootDir = tempRootDir.toUpperCase();249 } else {250 mismatchedRootDir = tempRootDir.toLowerCase();251 }252 }253 254 // 2. Create a new GlobTool instance with this mismatched root255 const mismatchedConfig = {256 ...mockConfig,257 getTargetDir: () => mismatchedRootDir,258 getWorkspaceContext: () =>259 createMockWorkspaceContext(mismatchedRootDir),260 } as unknown as Config;261 262 const mismatchedGlobTool = new GlobTool(mismatchedConfig);263 264 // 3. Execute search265 const params: GlobToolParams = { pattern: '*.txt' };266 const invocation = mismatchedGlobTool.build(params);267 const result = await invocation.execute(abortSignal);268 269 expect(result.llmContent).toContain('Found 2 file(s)');270 });271 272 it('should allow path outside workspace (external path support)', async () => {273 const params: GlobToolParams = { pattern: '*.txt', path: '/tmp' };274 const invocation = globTool.build(params);275 // External path is now allowed - it should not return a workspace error276 const result = await invocation.execute(abortSignal);277 expect(result.returnDisplay).not.toContain(278 'Path is not within workspace',279 );280 });281 282 it('should return a GLOB_EXECUTION_ERROR on glob failure', async () => {283 vi.mocked(glob.glob).mockRejectedValue(new Error('Glob failed'));284 const params: GlobToolParams = { pattern: '*.txt' };285 const invocation = globTool.build(params);286 const result = await invocation.execute(abortSignal);287 expect(result.error?.type).toBe(ToolErrorType.GLOB_EXECUTION_ERROR);288 expect(result.llmContent).toContain(289 'Error during glob search operation: Glob failed',290 );291 // Reset glob.292 vi.mocked(glob.glob).mockReset();293 });294 });295 296 describe('validateToolParams', () => {297 it('should return null for valid parameters (pattern only)', () => {298 const params: GlobToolParams = { pattern: '*.js' };299 expect(globTool.validateToolParams(params)).toBeNull();300 });301 302 it('should return null for valid parameters (pattern and path)', () => {303 const params: GlobToolParams = { pattern: '*.js', path: 'sub' };304 expect(globTool.validateToolParams(params)).toBeNull();305 });306 307 it('should return error if pattern is missing (schema validation)', () => {308 // Need to correctly define this as an object without pattern309 const params = { path: '.' };310 // @ts-expect-error - We're intentionally creating invalid params for testing311 expect(globTool.validateToolParams(params)).toBe(312 `params must have required property 'pattern'`,313 );314 });315 316 it('should return error if pattern is an empty string', () => {317 const params: GlobToolParams = { pattern: '' };318 expect(globTool.validateToolParams(params)).toContain(319 "The 'pattern' parameter cannot be empty.",320 );321 });322 323 it('should return error if pattern is only whitespace', () => {324 const params: GlobToolParams = { pattern: ' ' };325 expect(globTool.validateToolParams(params)).toContain(326 "The 'pattern' parameter cannot be empty.",327 );328 });329 330 it('should return error if path is provided but is not a string', () => {331 const params = {332 pattern: '*.ts',333 path: {},334 } as unknown as GlobToolParams; // Force incorrect type (object, not coercible)335 expect(globTool.validateToolParams(params)).toBe(336 'params/path must be string',337 );338 });339 340 it("should return error if search path resolves outside the tool's root directory", () => {341 // Create a globTool instance specifically for this test, with a deeper root342 tempRootDir = path.join(tempRootDir, 'sub');343 const specificGlobTool = new GlobTool(mockConfig);344 // const params: GlobToolParams = { pattern: '*.txt', path: '..' }; // This line is unused and will be removed.345 // This should be fine as tempRootDir is still within the original tempRootDir (the parent of deeperRootDir)346 // Let's try to go further up.347 const paramsOutside: GlobToolParams = {348 pattern: '*.txt',349 path: '../../../../../../../../../../tmp', // Definitely outside350 };351 // External paths are now allowed (permission handled at runtime)352 expect(specificGlobTool.validateToolParams(paramsOutside)).toBeNull();353 });354 355 it('should return error if specified search path does not exist', async () => {356 const params: GlobToolParams = {357 pattern: '*.txt',358 path: 'nonexistent_subdir',359 };360 expect(globTool.validateToolParams(params)).toContain(361 'Path does not exist',362 );363 });364 365 it('should return error if specified search path is a file, not a directory', async () => {366 const params: GlobToolParams = { pattern: '*.txt', path: 'fileA.txt' };367 expect(globTool.validateToolParams(params)).toContain(368 'Path is not a directory',369 );370 });371 372 it.skipIf(process.platform === 'win32')(373 'should unescape shell-escaped path',374 async () => {375 // Create a directory with a space so the unescaped path exists376 const dirWithSpace = path.join(tempRootDir, 'sub dir');377 await fs.mkdir(dirWithSpace);378 const params: GlobToolParams = {379 pattern: '*.ts',380 path: path.join(tempRootDir, 'sub\\ dir'),381 };382 expect(globTool.validateToolParams(params)).toBeNull();383 // Path should be normalized in place384 expect(params.path).toBe(dirWithSpace);385 },386 );387 });388 389 describe('workspace boundary validation', () => {390 it('should validate search paths are within workspace boundaries', () => {391 const validPath = { pattern: '*.ts', path: 'sub' };392 const invalidPath = { pattern: '*.ts', path: '../..' };393 394 expect(globTool.validateToolParams(validPath)).toBeNull();395 // External paths are now allowed (permission handled at runtime)396 expect(globTool.validateToolParams(invalidPath)).toBeNull();397 });398 399 it('should work with paths in workspace subdirectories', async () => {400 const params: GlobToolParams = { pattern: '*.md', path: 'sub' };401 const invocation = globTool.build(params);402 const result = await invocation.execute(abortSignal);403 404 expect(result.llmContent).toContain('Found 2 file(s)');405 expect(result.llmContent).toContain('fileC.md');406 expect(result.llmContent).toContain('FileD.MD');407 });408 });409 410 describe('multi-directory workspace', () => {411 it('should search across all workspace directories when no path is specified', async () => {412 // Create a second workspace directory413 const secondDir = await fs.mkdtemp(414 path.join(os.tmpdir(), 'glob-tool-second-'),415 );416 await fs.writeFile(path.join(secondDir, '.git'), ''); // Fake git repo417 await fs.writeFile(path.join(secondDir, 'extra.txt'), 'extra content');418 await fs.writeFile(path.join(secondDir, 'bonus.txt'), 'bonus content');419 420 const multiDirConfig = {421 ...mockConfig,422 getWorkspaceContext: () =>423 createMockWorkspaceContext(tempRootDir, [secondDir]),424 } as unknown as Config;425 426 const multiDirGlobTool = new GlobTool(multiDirConfig);427 const params: GlobToolParams = { pattern: '*.txt' };428 const invocation = multiDirGlobTool.build(params);429 const result = await invocation.execute(abortSignal);430 431 // Should find files from both directories432 expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));433 expect(result.llmContent).toContain(path.join(secondDir, 'extra.txt'));434 expect(result.llmContent).toContain(path.join(secondDir, 'bonus.txt'));435 expect(result.llmContent).toContain('across 2 workspace directories');436 437 await fs.rm(secondDir, { recursive: true, force: true });438 });439 440 it('should deduplicate entries across overlapping directories', async () => {441 // Use the same directory twice to test deduplication442 const multiDirConfig = {443 ...mockConfig,444 getWorkspaceContext: () =>445 createMockWorkspaceContext(tempRootDir, [tempRootDir]),446 } as unknown as Config;447 448 const multiDirGlobTool = new GlobTool(multiDirConfig);449 const params: GlobToolParams = { pattern: '*.txt' };450 const invocation = multiDirGlobTool.build(params);451 const result = await invocation.execute(abortSignal);452 453 // Should still only have 2 txt files (fileA.txt, FileB.TXT), not doubled454 expect(result.llmContent).toContain('Found 2 file(s)');455 });456 457 it('should use single directory description when only one workspace dir', async () => {458 const params: GlobToolParams = { pattern: '*.txt' };459 const invocation = globTool.build(params);460 const result = await invocation.execute(abortSignal);461 462 expect(result.llmContent).toContain('in the workspace directory');463 expect(result.llmContent).not.toContain('across');464 });465 466 it('should search only the specified path when path is provided (ignoring multi-dir)', async () => {467 const secondDir = await fs.mkdtemp(468 path.join(os.tmpdir(), 'glob-tool-second-'),469 );470 await fs.writeFile(path.join(secondDir, '.git'), '');471 await fs.writeFile(path.join(secondDir, 'other.txt'), 'other');472 473 const multiDirConfig = {474 ...mockConfig,475 getWorkspaceContext: () =>476 createMockWorkspaceContext(tempRootDir, [secondDir]),477 } as unknown as Config;478 479 const multiDirGlobTool = new GlobTool(multiDirConfig);480 const params: GlobToolParams = { pattern: '*.txt', path: 'sub' };481 const invocation = multiDirGlobTool.build(params);482 const result = await invocation.execute(abortSignal);483 484 // Should NOT find files from secondDir485 expect(result.llmContent).not.toContain('other.txt');486 487 await fs.rm(secondDir, { recursive: true, force: true });488 });489 });490 491 describe('ignore file handling', () => {492 it('should respect .gitignore files by default', async () => {493 await fs.writeFile(path.join(tempRootDir, '.gitignore'), '*.ignored.txt');494 await fs.writeFile(495 path.join(tempRootDir, 'a.ignored.txt'),496 'ignored content',497 );498 await fs.writeFile(499 path.join(tempRootDir, 'b.notignored.txt'),500 'not ignored content',501 );502 503 const params: GlobToolParams = { pattern: '*.txt' };504 const invocation = globTool.build(params);505 const result = await invocation.execute(abortSignal);506 507 expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, b.notignored.txt508 expect(result.llmContent).not.toContain('a.ignored.txt');509 });510 511 it('should respect .qwenignore files by default', async () => {512 await fs.writeFile(513 path.join(tempRootDir, '.qwenignore'),514 '*.qwenignored.txt',515 );516 await fs.writeFile(517 path.join(tempRootDir, 'a.qwenignored.txt'),518 'ignored content',519 );520 await fs.writeFile(521 path.join(tempRootDir, 'b.notignored.txt'),522 'not ignored content',523 );524 525 // Recreate the tool to pick up the new .qwenignore file526 globTool = new GlobTool(mockConfig);527 528 const params: GlobToolParams = { pattern: '*.txt' };529 const invocation = globTool.build(params);530 const result = await invocation.execute(abortSignal);531 532 expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, b.notignored.txt533 expect(result.llmContent).not.toContain('a.qwenignored.txt');534 });535 536 it('should respect .agentignore and .aiignore files by default', async () => {537 await fs.writeFile(538 path.join(tempRootDir, '.agentignore'),539 '*.agentignored.txt',540 );541 await fs.writeFile(542 path.join(tempRootDir, '.aiignore'),543 '*.aiignored.txt',544 );545 await fs.writeFile(546 path.join(tempRootDir, 'a.agentignored.txt'),547 'ignored content',548 );549 await fs.writeFile(550 path.join(tempRootDir, 'b.aiignored.txt'),551 'ignored content',552 );553 await fs.writeFile(554 path.join(tempRootDir, 'c.notignored.txt'),555 'not ignored content',556 );557 558 const params: GlobToolParams = { pattern: '*.txt' };559 const invocation = globTool.build(params);560 const result = await invocation.execute(abortSignal);561 562 expect(result.llmContent).toContain('c.notignored.txt');563 expect(result.llmContent).not.toContain('a.agentignored.txt');564 expect(result.llmContent).not.toContain('b.aiignored.txt');565 });566 567 it('should respect configured custom qwen ignore files', async () => {568 await fs.writeFile(569 path.join(tempRootDir, '.cursorignore'),570 '*.cursorignored.txt',571 );572 await fs.writeFile(573 path.join(tempRootDir, '.agentignore'),574 '*.agentignored.txt',575 );576 await fs.writeFile(577 path.join(tempRootDir, 'a.cursorignored.txt'),578 'ignored content',579 );580 await fs.writeFile(581 path.join(tempRootDir, 'b.agentignored.txt'),582 'not ignored by this config',583 );584 await fs.writeFile(585 path.join(tempRootDir, 'c.notignored.txt'),586 'not ignored content',587 );588 589 const customConfig = {590 ...mockConfig,591 getFileService: () =>592 new FileDiscoveryService(tempRootDir, ['.cursorignore']),593 getFileFilteringOptions: () => ({594 respectGitIgnore: true,595 respectQwenIgnore: true,596 customIgnoreFiles: ['.cursorignore'],597 }),598 } as unknown as Config;599 const customGlobTool = new GlobTool(customConfig);600 601 const params: GlobToolParams = { pattern: '*.txt' };602 const invocation = customGlobTool.build(params);603 const result = await invocation.execute(abortSignal);604 605 expect(result.llmContent).toContain('b.agentignored.txt');606 expect(result.llmContent).toContain('c.notignored.txt');607 expect(result.llmContent).not.toContain('a.cursorignored.txt');608 });609 610 it('should respect .gitignore when searching a subdirectory (path option)', async () => {611 // This tests the regression fix: relativePaths must be computed relative612 // to projectRoot, not to searchDir, so that gitignore rules rooted at613 // projectRoot are evaluated against the correct paths.614 await fs.writeFile(path.join(tempRootDir, '.gitignore'), '*.secret');615 await fs.writeFile(path.join(tempRootDir, 'sub', 'visible.txt'), 'ok');616 await fs.writeFile(617 path.join(tempRootDir, 'sub', 'hidden.secret'),618 'should be ignored',619 );620 621 const subDirTool = new GlobTool(mockConfig);622 const params: GlobToolParams = { pattern: '*', path: 'sub' };623 const invocation = subDirTool.build(params);624 const result = await invocation.execute(abortSignal);625 626 expect(result.llmContent).toContain('visible.txt');627 expect(result.llmContent).not.toContain('hidden.secret');628 });629 630 it('should respect .qwenignore when searching a subdirectory (path option)', async () => {631 await fs.writeFile(path.join(tempRootDir, '.qwenignore'), '*.secret');632 await fs.writeFile(path.join(tempRootDir, 'sub', 'visible.txt'), 'ok');633 await fs.writeFile(634 path.join(tempRootDir, 'sub', 'hidden.secret'),635 'should be ignored',636 );637 638 // Recreate to pick up .qwenignore639 const subDirTool = new GlobTool(mockConfig);640 const params: GlobToolParams = { pattern: '*', path: 'sub' };641 const invocation = subDirTool.build(params);642 const result = await invocation.execute(abortSignal);643 644 expect(result.llmContent).toContain('visible.txt');645 expect(result.llmContent).not.toContain('hidden.secret');646 });647 648 it('does not over-ignore nested dirs for a root-anchored gitignore pattern', async () => {649 // Regression: `/dist` is anchored to the repo root and must NOT exclude650 // a nested `src/dist`. Traversal pruning delegates to the real gitignore651 // logic, so anchoring is preserved (a lossy `/dist` -> `**/dist/**`652 // conversion would wrongly prune src/dist while walking).653 await fs.writeFile(path.join(tempRootDir, '.gitignore'), '/dist\n');654 await fs.mkdir(path.join(tempRootDir, 'dist'));655 await fs.writeFile(path.join(tempRootDir, 'dist', 'root.keep'), 'x');656 await fs.mkdir(path.join(tempRootDir, 'src', 'dist'), {657 recursive: true,658 });659 await fs.writeFile(660 path.join(tempRootDir, 'src', 'dist', 'nested.keep'),661 'x',662 );663 664 const invocation = new GlobTool(mockConfig).build({665 pattern: '**/*.keep',666 });667 const result = await invocation.execute(abortSignal);668 669 expect(result.llmContent).toContain('nested.keep');670 expect(result.llmContent).not.toContain('root.keep');671 });672 673 it('prunes a gitignored directory (e.g. node_modules) during traversal', async () => {674 await fs.writeFile(675 path.join(tempRootDir, '.gitignore'),676 'node_modules/\n',677 );678 await fs.mkdir(path.join(tempRootDir, 'node_modules', 'pkg'), {679 recursive: true,680 });681 await fs.writeFile(682 path.join(tempRootDir, 'node_modules', 'pkg', 'dep.keep'),683 'x',684 );685 await fs.mkdir(path.join(tempRootDir, 'app'));686 await fs.writeFile(path.join(tempRootDir, 'app', 'main.keep'), 'x');687 688 const invocation = new GlobTool(mockConfig).build({689 pattern: '**/*.keep',690 });691 const result = await invocation.execute(abortSignal);692 693 expect(result.llmContent).toContain('main.keep');694 expect(result.llmContent).not.toContain('dep.keep');695 });696 697 it('passes ignore callbacks to glob for traversal pruning', async () => {698 await fs.writeFile(699 path.join(tempRootDir, '.gitignore'),700 'node_modules/\n',701 );702 await fs.mkdir(path.join(tempRootDir, 'node_modules'));703 704 vi.mocked(glob.glob).mockClear();705 706 const invocation = new GlobTool(mockConfig).build({707 pattern: '**/*.keep',708 });709 await invocation.execute(abortSignal);710 711 const lastCall = vi.mocked(glob.glob).mock.calls.at(-1);712 const globOptions = lastCall?.[1] as713 | { ignore?: { ignored?: unknown; childrenIgnored?: unknown } }714 | undefined;715 expect(globOptions?.ignore).toBeDefined();716 expect(globOptions?.ignore?.ignored).toBeTypeOf('function');717 expect(globOptions?.ignore?.childrenIgnored).toBeTypeOf('function');718 });719 720 it('does not prune during traversal when respectGitIgnore is false', async () => {721 await fs.writeFile(722 path.join(tempRootDir, '.gitignore'),723 'node_modules/\n',724 );725 await fs.mkdir(path.join(tempRootDir, 'node_modules', 'pkg'), {726 recursive: true,727 });728 await fs.writeFile(729 path.join(tempRootDir, 'node_modules', 'pkg', 'dep.keep'),730 'x',731 );732 await fs.mkdir(path.join(tempRootDir, 'app'));733 await fs.writeFile(path.join(tempRootDir, 'app', 'main.keep'), 'x');734 735 const noGitIgnoreConfig = {736 ...mockConfig,737 getFileFilteringOptions: () => ({738 respectGitIgnore: false,739 respectQwenIgnore: true,740 }),741 } as unknown as Config;742 743 const invocation = new GlobTool(noGitIgnoreConfig).build({744 pattern: '**/*.keep',745 });746 const result = await invocation.execute(abortSignal);747 748 // gitignore disabled → the gitignored dir is not pruned; its file appears.749 expect(result.llmContent).toContain('dep.keep');750 expect(result.llmContent).toContain('main.keep');751 });752 753 it('does not prune entries outside the project root during traversal', async () => {754 // Root gitignores *.log; an external search dir containing a matching755 // file must NOT be pruned — ignore rules only apply within the root.756 await fs.writeFile(path.join(tempRootDir, '.gitignore'), '*.log\n');757 const externalDir = await fs.mkdtemp(758 path.join(os.tmpdir(), 'glob-external-'),759 );760 try {761 await fs.writeFile(path.join(externalDir, 'outside.log'), 'x');762 763 const invocation = new GlobTool(mockConfig).build({764 pattern: '*.log',765 path: externalDir,766 });767 const result = await invocation.execute(abortSignal);768 769 expect(result.llmContent).toContain('outside.log');770 } finally {771 await fs.rm(externalDir, { recursive: true, force: true });772 }773 });774 775 it('honors gitignore negation re-inclusion during traversal', async () => {776 // `!build/keep.keep` re-includes a file under an otherwise-ignored path.777 // Dropping negations (as a pattern conversion must) would wrongly prune778 // it; delegating to the real ignore logic preserves re-inclusion.779 await fs.writeFile(780 path.join(tempRootDir, '.gitignore'),781 'build/**\n!build/keep.keep\n',782 );783 await fs.mkdir(path.join(tempRootDir, 'build'));784 await fs.writeFile(path.join(tempRootDir, 'build', 'keep.keep'), 'x');785 await fs.writeFile(path.join(tempRootDir, 'build', 'skip.keep'), 'x');786 787 const invocation = new GlobTool(mockConfig).build({788 pattern: '**/*.keep',789 });790 const result = await invocation.execute(abortSignal);791 792 expect(result.llmContent).toContain('keep.keep');793 expect(result.llmContent).not.toContain('skip.keep');794 });795 });796 797 describe('file count truncation', () => {798 it('should truncate results when more than 100 files are found', async () => {799 mockTruncationGlobResults('file', 150);800 801 const params: GlobToolParams = { pattern: '*.trunctest' };802 const invocation = globTool.build(params);803 const result = await invocation.execute(abortSignal);804 const llmContent = partListUnionToString(result.llmContent);805 806 // Should report all 150 files found807 expect(llmContent).toContain('Found 150 file(s)');808 809 // Should include truncation notice810 expect(llmContent).toContain('[50 files truncated] ...');811 812 // Count the number of .trunctest files mentioned in the output813 const fileMatches = llmContent.match(/file\d+\.trunctest/g);814 expect(fileMatches).toBeDefined();815 expect(fileMatches?.length).toBe(100);816 817 // returnDisplay should indicate truncation818 expect(result.returnDisplay).toBe(819 'Found 150 matching file(s) (truncated)',820 );821 });822 823 it('should not truncate when exactly 100 files are found', async () => {824 mockTruncationGlobResults('exact', 100);825 826 const params: GlobToolParams = { pattern: '*.trunctest' };827 const invocation = globTool.build(params);828 const result = await invocation.execute(abortSignal);829 830 // Should report all 100 files found831 expect(result.llmContent).toContain('Found 100 file(s)');832 833 // Should NOT include truncation notice834 expect(result.llmContent).not.toContain('truncated');835 836 // Should show all 100 files837 expect(result.llmContent).toContain('exact1.trunctest');838 expect(result.llmContent).toContain('exact100.trunctest');839 840 // returnDisplay should NOT indicate truncation841 expect(result.returnDisplay).toBe('Found 100 matching file(s)');842 });843 844 it('should not truncate when fewer than 100 files are found', async () => {845 mockTruncationGlobResults('small', 50);846 847 const params: GlobToolParams = { pattern: '*.trunctest' };848 const invocation = globTool.build(params);849 const result = await invocation.execute(abortSignal);850 851 // Should report all 50 files found852 expect(result.llmContent).toContain('Found 50 file(s)');853 854 // Should NOT include truncation notice855 expect(result.llmContent).not.toContain('truncated');856 857 // returnDisplay should NOT indicate truncation858 expect(result.returnDisplay).toBe('Found 50 matching file(s)');859 });860 861 it('should use correct singular/plural in truncation message for 1 file truncated', async () => {862 mockTruncationGlobResults('singular', 101);863 864 const params: GlobToolParams = { pattern: '*.trunctest' };865 const invocation = globTool.build(params);866 const result = await invocation.execute(abortSignal);867 868 // Should use singular "file" for 1 truncated file869 expect(result.llmContent).toContain('[1 file truncated] ...');870 expect(result.llmContent).not.toContain('[1 files truncated]');871 });872 873 it('should use correct plural in truncation message for multiple files truncated', async () => {874 mockTruncationGlobResults('plural', 105);875 876 const params: GlobToolParams = { pattern: '*.trunctest' };877 const invocation = globTool.build(params);878 const result = await invocation.execute(abortSignal);879 880 // Should use plural "files" for multiple truncated files881 expect(result.llmContent).toContain('[5 files truncated] ...');882 });883 });884 885 describe('getDefaultPermission', () => {886 it('should return allow for paths within workspace', async () => {887 const params: GlobToolParams = { pattern: '*', path: 'sub' };888 const invocation = globTool.build(params);889 const permission = await invocation.getDefaultPermission();890 expect(permission).toBe('allow');891 });892 893 it('should return ask for tilde paths outside workspace', async () => {894 const params: GlobToolParams = {895 pattern: '*',896 path: '~/outside-workspace',897 };898 const invocation = globTool.build(params);899 const permission = await invocation.getDefaultPermission();900 expect(permission).toBe('ask');901 });902 });903});904 905describe('sortFileEntries', () => {906 const nowTimestamp = new Date('2024-01-15T12:00:00.000Z').getTime();907 const oneDayInMs = 24 * 60 * 60 * 1000;908 909 const createFileEntry = (fullpath: string, mtimeDate: Date): GlobPath => ({910 fullpath: () => fullpath,911 mtimeMs: mtimeDate.getTime(),912 });913 914 it('should sort a mix of recent and older files correctly', () => {915 const recentTime1 = new Date(nowTimestamp - 1 * 60 * 60 * 1000); // 1 hour ago916 const recentTime2 = new Date(nowTimestamp - 2 * 60 * 60 * 1000); // 2 hours ago917 const olderTime1 = new Date(918 nowTimestamp - (oneDayInMs + 1 * 60 * 60 * 1000),919 ); // 25 hours ago920 const olderTime2 = new Date(921 nowTimestamp - (oneDayInMs + 2 * 60 * 60 * 1000),922 ); // 26 hours ago923 924 const entries: GlobPath[] = [925 createFileEntry('older_zebra.txt', olderTime2),926 createFileEntry('recent_alpha.txt', recentTime1),927 createFileEntry('older_apple.txt', olderTime1),928 createFileEntry('recent_beta.txt', recentTime2),929 createFileEntry('older_banana.txt', olderTime1), // Same mtime as apple930 ];931 932 const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs);933 const sortedPaths = sorted.map((e) => e.fullpath());934 935 expect(sortedPaths).toEqual([936 'recent_alpha.txt', // Recent, newest937 'recent_beta.txt', // Recent, older938 'older_apple.txt', // Older, alphabetical939 'older_banana.txt', // Older, alphabetical940 'older_zebra.txt', // Older, alphabetical941 ]);942 });943 944 it('should sort only recent files by mtime descending', () => {945 const recentTime1 = new Date(nowTimestamp - 1000); // Newest946 const recentTime2 = new Date(nowTimestamp - 2000);947 const recentTime3 = new Date(nowTimestamp - 3000); // Oldest recent948 949 const entries: GlobPath[] = [950 createFileEntry('c.txt', recentTime2),951 createFileEntry('a.txt', recentTime3),952 createFileEntry('b.txt', recentTime1),953 ];954 const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs);955 expect(sorted.map((e) => e.fullpath())).toEqual([956 'b.txt',957 'c.txt',958 'a.txt',959 ]);960 });961 962 it('should sort only older files alphabetically by path', () => {963 const olderTime = new Date(nowTimestamp - 2 * oneDayInMs); // All equally old964 const entries: GlobPath[] = [965 createFileEntry('zebra.txt', olderTime),966 createFileEntry('apple.txt', olderTime),967 createFileEntry('banana.txt', olderTime),968 ];969 const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs);970 expect(sorted.map((e) => e.fullpath())).toEqual([971 'apple.txt',972 'banana.txt',973 'zebra.txt',974 ]);975 });976 977 it('should handle an empty array', () => {978 const entries: GlobPath[] = [];979 const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs);980 expect(sorted).toEqual([]);981 });982 983 it('should correctly sort files when mtimes are identical for older files', () => {984 const olderTime = new Date(nowTimestamp - 2 * oneDayInMs);985 const entries: GlobPath[] = [986 createFileEntry('b.txt', olderTime),987 createFileEntry('a.txt', olderTime),988 ];989 const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs);990 expect(sorted.map((e) => e.fullpath())).toEqual(['a.txt', 'b.txt']);991 });992 993 it('should correctly sort files when mtimes are identical for recent files (maintaining mtime sort)', () => {994 const recentTime = new Date(nowTimestamp - 1000);995 const entries: GlobPath[] = [996 createFileEntry('b.txt', recentTime),997 createFileEntry('a.txt', recentTime),998 ];999 const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs);1000 expect(sorted.map((e) => e.fullpath())).toContain('a.txt');1001 expect(sorted.map((e) => e.fullpath())).toContain('b.txt');1002 expect(sorted.length).toBe(2);1003 });1004 1005 it('should use recencyThresholdMs parameter correctly', () => {1006 const justOverThreshold = new Date(nowTimestamp - (1000 + 1)); // Barely older1007 const justUnderThreshold = new Date(nowTimestamp - (1000 - 1)); // Barely recent1008 const customThresholdMs = 1000; // 1 second1009 1010 const entries: GlobPath[] = [1011 createFileEntry('older_file.txt', justOverThreshold),1012 createFileEntry('recent_file.txt', justUnderThreshold),1013 ];1014 const sorted = sortFileEntries(entries, nowTimestamp, customThresholdMs);1015 expect(sorted.map((e) => e.fullpath())).toEqual([1016 'recent_file.txt',1017 'older_file.txt',1018 ]);1019 });1020});1021 