CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
glob.test.ts1021 linesDownload Raw Back to tools
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 
basant307/AI_Governance_Project · CoolFace