sync gemini-cli 0.1.17

Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Yiheng Xu
2025-08-05 16:44:06 +08:00
235 changed files with 16997 additions and 3736 deletions

View File

@@ -189,4 +189,81 @@ describe('bfsFileSearch', () => {
expect(result.sort()).toEqual([target1, target2].sort());
});
});
it('should perform parallel directory scanning efficiently (performance test)', async () => {
// Create a more complex directory structure for performance testing
console.log('\n🚀 Testing Parallel BFS Performance...');
// Create 50 directories with multiple levels for faster test execution
for (let i = 0; i < 50; i++) {
await createEmptyDir(`dir${i}`);
await createEmptyDir(`dir${i}`, 'subdir1');
await createEmptyDir(`dir${i}`, 'subdir2');
await createEmptyDir(`dir${i}`, 'subdir1', 'deep');
if (i < 10) {
// Add target files in some directories
await createTestFile('content', `dir${i}`, 'GEMINI.md');
await createTestFile('content', `dir${i}`, 'subdir1', 'GEMINI.md');
}
}
// Run multiple iterations to ensure consistency
const iterations = 3;
const durations: number[] = [];
let foundFiles = 0;
let firstResultSorted: string[] | undefined;
for (let i = 0; i < iterations; i++) {
const searchStartTime = performance.now();
const result = await bfsFileSearch(testRootDir, {
fileName: 'GEMINI.md',
maxDirs: 200,
debug: false,
});
const duration = performance.now() - searchStartTime;
durations.push(duration);
// Verify consistency: all iterations should find the exact same files
if (firstResultSorted === undefined) {
foundFiles = result.length;
firstResultSorted = result.sort();
} else {
expect(result.sort()).toEqual(firstResultSorted);
}
console.log(`📊 Iteration ${i + 1}: ${duration.toFixed(2)}ms`);
}
const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
const maxDuration = Math.max(...durations);
const minDuration = Math.min(...durations);
console.log(`📊 Average Duration: ${avgDuration.toFixed(2)}ms`);
console.log(
`📊 Min/Max Duration: ${minDuration.toFixed(2)}ms / ${maxDuration.toFixed(2)}ms`,
);
console.log(`📁 Found ${foundFiles} GEMINI.md files`);
console.log(
`🏎️ Processing ~${Math.round(200 / (avgDuration / 1000))} dirs/second`,
);
// Verify we found the expected files
expect(foundFiles).toBe(20); // 10 dirs * 2 files each
// Performance expectation: check consistency rather than absolute time
const variance = maxDuration - minDuration;
const consistencyRatio = variance / avgDuration;
// Ensure reasonable performance (generous limit for CI environments)
expect(avgDuration).toBeLessThan(2000); // Very generous limit
// Ensure consistency across runs (variance should not be too high)
// More tolerant in CI environments where performance can be variable
const maxConsistencyRatio = process.env.CI ? 3.0 : 1.5;
expect(consistencyRatio).toBeLessThan(maxConsistencyRatio); // Max variance should be reasonable
console.log(
`✅ Performance test passed: avg=${avgDuration.toFixed(2)}ms, consistency=${(consistencyRatio * 100).toFixed(1)}% (threshold: ${(maxConsistencyRatio * 100).toFixed(0)}%)`,
);
});
});

View File

@@ -6,7 +6,6 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { Dirent } from 'fs';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { FileFilteringOptions } from '../config/config.js';
// Simple console logger for now.
@@ -47,45 +46,76 @@ export async function bfsFileSearch(
const queue: string[] = [rootDir];
const visited = new Set<string>();
let scannedDirCount = 0;
let queueHead = 0; // Pointer-based queue head to avoid expensive splice operations
while (queue.length > 0 && scannedDirCount < maxDirs) {
const currentDir = queue.shift()!;
if (visited.has(currentDir)) {
continue;
// Convert ignoreDirs array to Set for O(1) lookup performance
const ignoreDirsSet = new Set(ignoreDirs);
// Process directories in parallel batches for maximum performance
const PARALLEL_BATCH_SIZE = 15; // Parallel processing batch size for optimal performance
while (queueHead < queue.length && scannedDirCount < maxDirs) {
// Fill batch with unvisited directories up to the desired size
const batchSize = Math.min(PARALLEL_BATCH_SIZE, maxDirs - scannedDirCount);
const currentBatch = [];
while (currentBatch.length < batchSize && queueHead < queue.length) {
const currentDir = queue[queueHead];
queueHead++;
if (!visited.has(currentDir)) {
visited.add(currentDir);
currentBatch.push(currentDir);
}
}
visited.add(currentDir);
scannedDirCount++;
scannedDirCount += currentBatch.length;
if (currentBatch.length === 0) continue;
if (debug) {
logger.debug(`Scanning [${scannedDirCount}/${maxDirs}]: ${currentDir}`);
logger.debug(
`Scanning [${scannedDirCount}/${maxDirs}]: batch of ${currentBatch.length}`,
);
}
let entries: Dirent[];
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
// Ignore errors for directories we can't read (e.g., permissions)
continue;
}
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (
fileService?.shouldIgnoreFile(fullPath, {
respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore,
respectGeminiIgnore:
options.fileFilteringOptions?.respectGeminiIgnore,
})
) {
continue;
}
if (entry.isDirectory()) {
if (!ignoreDirs.includes(entry.name)) {
queue.push(fullPath);
// Read directories in parallel instead of one by one
const readPromises = currentBatch.map(async (currentDir) => {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
return { currentDir, entries };
} catch (error) {
// Warn user that a directory could not be read, as this affects search results.
const message = (error as Error)?.message ?? 'Unknown error';
console.warn(
`[WARN] Skipping unreadable directory: ${currentDir} (${message})`,
);
if (debug) {
logger.debug(`Full error for ${currentDir}:`, error);
}
return { currentDir, entries: [] };
}
});
const results = await Promise.all(readPromises);
for (const { currentDir, entries } of results) {
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (
fileService?.shouldIgnoreFile(fullPath, {
respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore,
respectGeminiIgnore:
options.fileFilteringOptions?.respectGeminiIgnore,
})
) {
continue;
}
if (entry.isDirectory()) {
if (!ignoreDirsSet.has(entry.name)) {
queue.push(fullPath);
}
} else if (entry.isFile() && entry.name === fileName) {
foundFiles.push(fullPath);
}
} else if (entry.isFile() && entry.name === fileName) {
foundFiles.push(fullPath);
}
}
}

View File

@@ -17,14 +17,14 @@ import { ReadFileTool } from '../tools/read-file.js';
import { ReadManyFilesTool } from '../tools/read-many-files.js';
import { GrepTool } from '../tools/grep.js';
import { LruCache } from './LruCache.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
import {
isFunctionResponse,
isFunctionCall,
} from '../utils/messageInspectors.js';
import * as fs from 'fs';
const EditModel = DEFAULT_GEMINI_FLASH_MODEL;
const EditModel = DEFAULT_GEMINI_FLASH_LITE_MODEL;
const EditConfig: GenerateContentConfig = {
thinkingConfig: {
thinkingBudget: 0,

View File

@@ -70,6 +70,7 @@ describe('editor utils', () => {
{ editor: 'vim', commands: ['vim'], win32Commands: ['vim'] },
{ editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] },
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{ editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] },
];
for (const { editor, commands, win32Commands } of testCases) {
@@ -297,6 +298,14 @@ describe('editor utils', () => {
});
}
it('should return the correct command for emacs', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'emacs');
expect(command).toEqual({
command: 'emacs',
args: ['--eval', '(ediff "old.txt" "new.txt")'],
});
});
it('should return null for an unsupported editor', () => {
// @ts-expect-error Testing unsupported editor
const command = getDiffCommand('old.txt', 'new.txt', 'foobar');
@@ -372,7 +381,7 @@ describe('editor utils', () => {
});
}
const execSyncEditors: EditorType[] = ['vim', 'neovim'];
const execSyncEditors: EditorType[] = ['vim', 'neovim', 'emacs'];
for (const editor of execSyncEditors) {
it(`should call execSync for ${editor} on non-windows`, async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
@@ -425,6 +434,15 @@ describe('editor utils', () => {
expect(allowEditorTypeInSandbox('vim')).toBe(true);
});
it('should allow emacs in sandbox mode', () => {
process.env.SANDBOX = 'sandbox';
expect(allowEditorTypeInSandbox('emacs')).toBe(true);
});
it('should allow emacs when not in sandbox mode', () => {
expect(allowEditorTypeInSandbox('emacs')).toBe(true);
});
it('should allow neovim in sandbox mode', () => {
process.env.SANDBOX = 'sandbox';
expect(allowEditorTypeInSandbox('neovim')).toBe(true);
@@ -490,6 +508,12 @@ describe('editor utils', () => {
expect(isEditorAvailable('vim')).toBe(true);
});
it('should return true for emacs when installed and in sandbox mode', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/emacs'));
process.env.SANDBOX = 'sandbox';
expect(isEditorAvailable('emacs')).toBe(true);
});
it('should return true for neovim when installed and in sandbox mode', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/nvim'));
process.env.SANDBOX = 'sandbox';

View File

@@ -13,7 +13,8 @@ export type EditorType =
| 'cursor'
| 'vim'
| 'neovim'
| 'zed';
| 'zed'
| 'emacs';
function isValidEditorType(editor: string): editor is EditorType {
return [
@@ -24,6 +25,7 @@ function isValidEditorType(editor: string): editor is EditorType {
'vim',
'neovim',
'zed',
'emacs',
].includes(editor);
}
@@ -59,6 +61,7 @@ const editorCommands: Record<
vim: { win32: ['vim'], default: ['vim'] },
neovim: { win32: ['nvim'], default: ['nvim'] },
zed: { win32: ['zed'], default: ['zed', 'zeditor'] },
emacs: { win32: ['emacs.exe'], default: ['emacs'] },
};
export function checkHasEditorType(editor: EditorType): boolean {
@@ -73,6 +76,7 @@ export function allowEditorTypeInSandbox(editor: EditorType): boolean {
if (['vscode', 'vscodium', 'windsurf', 'cursor', 'zed'].includes(editor)) {
return notUsingSandbox;
}
// For terminal-based editors like vim and emacs, allow in sandbox.
return true;
}
@@ -141,6 +145,11 @@ export function getDiffCommand(
newPath,
],
};
case 'emacs':
return {
command: 'emacs',
args: ['--eval', `(ediff "${oldPath}" "${newPath}")`],
};
default:
return null;
}
@@ -190,6 +199,7 @@ export async function openDiff(
});
case 'vim':
case 'emacs':
case 'neovim': {
// Use execSync for terminal-based editors
const command =

View File

@@ -420,7 +420,7 @@ describe('fileUtils', () => {
expect(result.llmContent).toContain(
'[File content truncated: showing lines 6-10 of 20 total lines. Use offset/limit parameters to view more.]',
);
expect(result.returnDisplay).toBe('(truncated)');
expect(result.returnDisplay).toBe('Read lines 6-10 of 20 from test.txt');
expect(result.isTruncated).toBe(true);
expect(result.originalLineCount).toBe(20);
expect(result.linesShown).toEqual([6, 10]);
@@ -465,9 +465,72 @@ describe('fileUtils', () => {
expect(result.llmContent).toContain(
'[File content partially truncated: some lines exceeded maximum length of 2000 characters.]',
);
expect(result.returnDisplay).toBe(
'Read all 3 lines from test.txt (some lines were shortened)',
);
expect(result.isTruncated).toBe(true);
});
it('should truncate when line count exceeds the limit', async () => {
const lines = Array.from({ length: 11 }, (_, i) => `Line ${i + 1}`);
actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n'));
// Read 5 lines, but there are 11 total
const result = await processSingleFileContent(
testTextFilePath,
tempRootDir,
0,
5,
);
expect(result.isTruncated).toBe(true);
expect(result.returnDisplay).toBe('Read lines 1-5 of 11 from test.txt');
});
it('should truncate when a line length exceeds the character limit', async () => {
const longLine = 'b'.repeat(2500);
const lines = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`);
lines.push(longLine); // Total 11 lines
actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n'));
// Read all 11 lines, including the long one
const result = await processSingleFileContent(
testTextFilePath,
tempRootDir,
0,
11,
);
expect(result.isTruncated).toBe(true);
expect(result.returnDisplay).toBe(
'Read all 11 lines from test.txt (some lines were shortened)',
);
});
it('should truncate both line count and line length when both exceed limits', async () => {
const linesWithLongInMiddle = Array.from(
{ length: 20 },
(_, i) => `Line ${i + 1}`,
);
linesWithLongInMiddle[4] = 'c'.repeat(2500);
actualNodeFs.writeFileSync(
testTextFilePath,
linesWithLongInMiddle.join('\n'),
);
// Read 10 lines out of 20, including the long line
const result = await processSingleFileContent(
testTextFilePath,
tempRootDir,
0,
10,
);
expect(result.isTruncated).toBe(true);
expect(result.returnDisplay).toBe(
'Read lines 1-10 of 20 from test.txt (some lines were shortened)',
);
});
it('should return an error if the file size exceeds 20MB', async () => {
// Create a file just over 20MB
const twentyOneMB = 21 * 1024 * 1024;

View File

@@ -310,9 +310,22 @@ export async function processSingleFileContent(
}
llmTextContent += formattedLines.join('\n');
// By default, return nothing to streamline the common case of a successful read_file.
let returnDisplay = '';
if (contentRangeTruncated) {
returnDisplay = `Read lines ${
actualStartLine + 1
}-${endLine} of ${originalLineCount} from ${relativePathForDisplay}`;
if (linesWereTruncatedInLength) {
returnDisplay += ' (some lines were shortened)';
}
} else if (linesWereTruncatedInLength) {
returnDisplay = `Read all ${originalLineCount} lines from ${relativePathForDisplay} (some lines were shortened)`;
}
return {
llmContent: llmTextContent,
returnDisplay: isTruncated ? '(truncated)' : '',
returnDisplay,
isTruncated,
originalLineCount,
linesShown: [actualStartLine + 1, endLine],

View File

@@ -6,6 +6,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Config } from '../config/config.js';
import fs from 'node:fs';
import {
setSimulate429,
disableSimulationAfterFallback,
@@ -16,17 +17,25 @@ import {
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { retryWithBackoff } from './retry.js';
import { AuthType } from '../core/contentGenerator.js';
import { IdeClient } from '../ide/ide-client.js';
vi.mock('node:fs');
describe('Flash Fallback Integration', () => {
let config: Config;
beforeEach(() => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
config = new Config({
sessionId: 'test-session',
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: 'gemini-2.5-pro',
ideClient: IdeClient.getInstance(false),
});
// Reset simulation state for each test

View File

@@ -305,10 +305,12 @@ Subdir memory
false,
new FileDiscoveryService(projectRoot),
[],
'tree',
{
respectGitIgnore: true,
respectGeminiIgnore: true,
},
200, // maxDirs parameter
);
expect(result).toEqual({
@@ -334,6 +336,7 @@ My code memory
true,
new FileDiscoveryService(projectRoot),
[],
'tree', // importFormat
{
respectGitIgnore: true,
respectGeminiIgnore: true,

View File

@@ -43,7 +43,7 @@ async function findProjectRoot(startDir: string): Promise<string | null> {
while (true) {
const gitPath = path.join(currentDir, '.git');
try {
const stats = await fs.stat(gitPath);
const stats = await fs.lstat(gitPath);
if (stats.isDirectory()) {
return currentDir;
}
@@ -94,7 +94,6 @@ async function getGeminiMdFilePathsInternal(
const geminiMdFilenames = getAllGeminiMdFilenames();
for (const geminiMdFilename of geminiMdFilenames) {
const resolvedCwd = path.resolve(currentWorkingDirectory);
const resolvedHome = path.resolve(userHomePath);
const globalMemoryPath = path.join(
resolvedHome,
@@ -102,12 +101,7 @@ async function getGeminiMdFilePathsInternal(
geminiMdFilename,
);
if (debugMode)
logger.debug(
`Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`,
);
if (debugMode) logger.debug(`User home directory: ${resolvedHome}`);
// This part that finds the global file always runs.
try {
await fs.access(globalMemoryPath, fsSync.constants.R_OK);
allPaths.add(globalMemoryPath);
@@ -116,102 +110,71 @@ async function getGeminiMdFilePathsInternal(
`Found readable global ${geminiMdFilename}: ${globalMemoryPath}`,
);
} catch {
// It's okay if it's not found.
}
// FIX: Only perform the workspace search (upward and downward scans)
// if a valid currentWorkingDirectory is provided.
if (currentWorkingDirectory) {
const resolvedCwd = path.resolve(currentWorkingDirectory);
if (debugMode)
logger.debug(
`Global ${geminiMdFilename} not found or not readable: ${globalMemoryPath}`,
`Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`,
);
}
const projectRoot = await findProjectRoot(resolvedCwd);
if (debugMode)
logger.debug(`Determined project root: ${projectRoot ?? 'None'}`);
const projectRoot = await findProjectRoot(resolvedCwd);
if (debugMode)
logger.debug(`Determined project root: ${projectRoot ?? 'None'}`);
const upwardPaths: string[] = [];
let currentDir = resolvedCwd;
// Determine the directory that signifies the top of the project or user-specific space.
const ultimateStopDir = projectRoot
? path.dirname(projectRoot)
: path.dirname(resolvedHome);
const upwardPaths: string[] = [];
let currentDir = resolvedCwd;
const ultimateStopDir = projectRoot
? path.dirname(projectRoot)
: path.dirname(resolvedHome);
while (currentDir && currentDir !== path.dirname(currentDir)) {
// Loop until filesystem root or currentDir is empty
if (debugMode) {
logger.debug(
`Checking for ${geminiMdFilename} in (upward scan): ${currentDir}`,
);
}
// Skip the global .gemini directory itself during upward scan from CWD,
// as global is handled separately and explicitly first.
if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) {
if (debugMode) {
logger.debug(
`Upward scan reached global config dir path, stopping upward search here: ${currentDir}`,
);
while (currentDir && currentDir !== path.dirname(currentDir)) {
if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) {
break;
}
break;
}
const potentialPath = path.join(currentDir, geminiMdFilename);
try {
await fs.access(potentialPath, fsSync.constants.R_OK);
// Add to upwardPaths only if it's not the already added globalMemoryPath
if (potentialPath !== globalMemoryPath) {
upwardPaths.unshift(potentialPath);
if (debugMode) {
logger.debug(
`Found readable upward ${geminiMdFilename}: ${potentialPath}`,
);
const potentialPath = path.join(currentDir, geminiMdFilename);
try {
await fs.access(potentialPath, fsSync.constants.R_OK);
if (potentialPath !== globalMemoryPath) {
upwardPaths.unshift(potentialPath);
}
} catch {
// Not found, continue.
}
} catch {
if (debugMode) {
logger.debug(
`Upward ${geminiMdFilename} not found or not readable in: ${currentDir}`,
);
if (currentDir === ultimateStopDir) {
break;
}
currentDir = path.dirname(currentDir);
}
upwardPaths.forEach((p) => allPaths.add(p));
// Stop condition: if currentDir is the ultimateStopDir, break after this iteration.
if (currentDir === ultimateStopDir) {
if (debugMode)
logger.debug(
`Reached ultimate stop directory for upward scan: ${currentDir}`,
);
break;
const mergedOptions = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
...fileFilteringOptions,
};
const downwardPaths = await bfsFileSearch(resolvedCwd, {
fileName: geminiMdFilename,
maxDirs,
debug: debugMode,
fileService,
fileFilteringOptions: mergedOptions,
});
downwardPaths.sort();
for (const dPath of downwardPaths) {
allPaths.add(dPath);
}
currentDir = path.dirname(currentDir);
}
upwardPaths.forEach((p) => allPaths.add(p));
// Merge options with memory defaults, with options taking precedence
const mergedOptions = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
...fileFilteringOptions,
};
const downwardPaths = await bfsFileSearch(resolvedCwd, {
fileName: geminiMdFilename,
maxDirs,
debug: debugMode,
fileService,
fileFilteringOptions: mergedOptions, // Pass merged options as fileFilter
});
downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex
if (debugMode && downwardPaths.length > 0)
logger.debug(
`Found downward ${geminiMdFilename} files (sorted): ${JSON.stringify(
downwardPaths,
)}`,
);
// Add downward paths only if they haven't been included already (e.g. from upward scan)
for (const dPath of downwardPaths) {
allPaths.add(dPath);
}
}
// Add extension context file paths
// Add extension context file paths.
for (const extensionPath of extensionContextFilePaths) {
allPaths.add(extensionPath);
}
@@ -230,6 +193,7 @@ async function getGeminiMdFilePathsInternal(
async function readGeminiMdFiles(
filePaths: string[],
debugMode: boolean,
importFormat: 'flat' | 'tree' = 'tree',
): Promise<GeminiFileContent[]> {
const results: GeminiFileContent[] = [];
for (const filePath of filePaths) {
@@ -237,16 +201,19 @@ async function readGeminiMdFiles(
const content = await fs.readFile(filePath, 'utf-8');
// Process imports in the content
const processedContent = await processImports(
const processedResult = await processImports(
content,
path.dirname(filePath),
debugMode,
undefined,
undefined,
importFormat,
);
results.push({ filePath, content: processedContent });
results.push({ filePath, content: processedResult.content });
if (debugMode)
logger.debug(
`Successfully read and processed imports: ${filePath} (Length: ${processedContent.length})`,
`Successfully read and processed imports: ${filePath} (Length: ${processedResult.content.length})`,
);
} catch (error: unknown) {
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST;
@@ -293,12 +260,13 @@ export async function loadServerHierarchicalMemory(
debugMode: boolean,
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
importFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
maxDirs: number = 200,
): Promise<{ memoryContent: string; fileCount: number }> {
if (debugMode)
logger.debug(
`Loading server hierarchical memory for CWD: ${currentWorkingDirectory}`,
`Loading server hierarchical memory for CWD: ${currentWorkingDirectory} (importFormat: ${importFormat})`,
);
// For the server, homedir() refers to the server process's home.
@@ -317,7 +285,11 @@ export async function loadServerHierarchicalMemory(
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');
return { memoryContent: '', fileCount: 0 };
}
const contentsWithPaths = await readGeminiMdFiles(filePaths, debugMode);
const contentsWithPaths = await readGeminiMdFiles(
filePaths,
debugMode,
importFormat,
);
// Pass CWD for relative path display in concatenated content
const combinedInstructions = concatenateInstructions(
contentsWithPaths,

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { marked } from 'marked';
// Simple console logger for import processing
const logger = {
@@ -29,15 +30,176 @@ interface ImportState {
currentFile?: string; // Track the current file being processed
}
/**
* Interface representing a file in the import tree
*/
export interface MemoryFile {
path: string;
imports?: MemoryFile[]; // Direct imports, in the order they were imported
}
/**
* Result of processing imports
*/
export interface ProcessImportsResult {
content: string;
importTree: MemoryFile;
}
// Helper to find the project root (looks for .git directory)
async function findProjectRoot(startDir: string): Promise<string> {
let currentDir = path.resolve(startDir);
while (true) {
const gitPath = path.join(currentDir, '.git');
try {
const stats = await fs.lstat(gitPath);
if (stats.isDirectory()) {
return currentDir;
}
} catch {
// .git not found, continue to parent
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
// Reached filesystem root
break;
}
currentDir = parentDir;
}
// Fallback to startDir if .git not found
return path.resolve(startDir);
}
// Add a type guard for error objects
function hasMessage(err: unknown): err is { message: string } {
return (
typeof err === 'object' &&
err !== null &&
'message' in err &&
typeof (err as { message: unknown }).message === 'string'
);
}
// Helper to find all code block and inline code regions using marked
/**
* Finds all import statements in content without using regex
* @returns Array of {start, _end, path} objects for each import found
*/
function findImports(
content: string,
): Array<{ start: number; _end: number; path: string }> {
const imports: Array<{ start: number; _end: number; path: string }> = [];
let i = 0;
const len = content.length;
while (i < len) {
// Find next @ symbol
i = content.indexOf('@', i);
if (i === -1) break;
// Check if it's a word boundary (not part of another word)
if (i > 0 && !isWhitespace(content[i - 1])) {
i++;
continue;
}
// Find the end of the import path (whitespace or newline)
let j = i + 1;
while (
j < len &&
!isWhitespace(content[j]) &&
content[j] !== '\n' &&
content[j] !== '\r'
) {
j++;
}
// Extract the path (everything after @)
const importPath = content.slice(i + 1, j);
// Basic validation (starts with ./ or / or letter)
if (
importPath.length > 0 &&
(importPath[0] === '.' ||
importPath[0] === '/' ||
isLetter(importPath[0]))
) {
imports.push({
start: i,
_end: j,
path: importPath,
});
}
i = j + 1;
}
return imports;
}
function isWhitespace(char: string): boolean {
return char === ' ' || char === '\t' || char === '\n' || char === '\r';
}
function isLetter(char: string): boolean {
const code = char.charCodeAt(0);
return (
(code >= 65 && code <= 90) || // A-Z
(code >= 97 && code <= 122)
); // a-z
}
function findCodeRegions(content: string): Array<[number, number]> {
const regions: Array<[number, number]> = [];
const tokens = marked.lexer(content);
// Map from raw content to a queue of its start indices in the original content.
const rawContentIndices = new Map<string, number[]>();
function walk(token: { type: string; raw: string; tokens?: unknown[] }) {
if (token.type === 'code' || token.type === 'codespan') {
if (!rawContentIndices.has(token.raw)) {
const indices: number[] = [];
let lastIndex = -1;
while ((lastIndex = content.indexOf(token.raw, lastIndex + 1)) !== -1) {
indices.push(lastIndex);
}
rawContentIndices.set(token.raw, indices);
}
const indices = rawContentIndices.get(token.raw);
if (indices && indices.length > 0) {
// Assume tokens are processed in order of appearance.
// Dequeue the next available index for this raw content.
const idx = indices.shift()!;
regions.push([idx, idx + token.raw.length]);
}
}
if ('tokens' in token && token.tokens) {
for (const child of token.tokens) {
walk(child as { type: string; raw: string; tokens?: unknown[] });
}
}
}
for (const token of tokens) {
walk(token);
}
return regions;
}
/**
* Processes import statements in GEMINI.md content
* Supports @path/to/file.md syntax for importing content from other files
*
* Supports @path/to/file syntax for importing content from other files
* @param content - The content to process for imports
* @param basePath - The directory path where the current file is located
* @param debugMode - Whether to enable debug logging
* @param importState - State tracking for circular import prevention
* @returns Processed content with imports resolved
* @param projectRoot - The project root directory for allowed directories
* @param importFormat - The format of the import tree
* @returns Processed content with imports resolved and import tree
*/
export async function processImports(
content: string,
@@ -45,156 +207,198 @@ export async function processImports(
debugMode: boolean = false,
importState: ImportState = {
processedFiles: new Set(),
maxDepth: 10,
maxDepth: 5,
currentDepth: 0,
},
): Promise<string> {
projectRoot?: string,
importFormat: 'flat' | 'tree' = 'tree',
): Promise<ProcessImportsResult> {
if (!projectRoot) {
projectRoot = await findProjectRoot(basePath);
}
if (importState.currentDepth >= importState.maxDepth) {
if (debugMode) {
logger.warn(
`Maximum import depth (${importState.maxDepth}) reached. Stopping import processing.`,
);
}
return content;
return {
content,
importTree: { path: importState.currentFile || 'unknown' },
};
}
// Regex to match @path/to/file imports (supports any file extension)
// Supports both @path/to/file.md and @./path/to/file.md syntax
const importRegex = /@([./]?[^\s\n]+\.[^\s\n]+)/g;
// --- FLAT FORMAT LOGIC ---
if (importFormat === 'flat') {
// Use a queue to process files in order of first encounter, and a set to avoid duplicates
const flatFiles: Array<{ path: string; content: string }> = [];
// Track processed files across the entire operation
const processedFiles = new Set<string>();
let processedContent = content;
let match: RegExpExecArray | null;
// Helper to recursively process imports
async function processFlat(
fileContent: string,
fileBasePath: string,
filePath: string,
depth: number,
) {
// Normalize the file path to ensure consistent comparison
const normalizedPath = path.normalize(filePath);
// Process all imports in the content
while ((match = importRegex.exec(content)) !== null) {
const importPath = match[1];
// Skip if already processed
if (processedFiles.has(normalizedPath)) return;
// Validate import path to prevent path traversal attacks
if (!validateImportPath(importPath, basePath, [basePath])) {
processedContent = processedContent.replace(
match[0],
`<!-- Import failed: ${importPath} - Path traversal attempt -->`,
);
continue;
}
// Mark as processed before processing to prevent infinite recursion
processedFiles.add(normalizedPath);
// Check if the import is for a non-md file and warn
if (!importPath.endsWith('.md')) {
logger.warn(
`Import processor only supports .md files. Attempting to import non-md file: ${importPath}. This will fail.`,
);
// Replace the import with a warning comment
processedContent = processedContent.replace(
match[0],
`<!-- Import failed: ${importPath} - Only .md files are supported -->`,
);
continue;
}
// Add this file to the flat list
flatFiles.push({ path: normalizedPath, content: fileContent });
const fullPath = path.resolve(basePath, importPath);
// Find imports in this file
const codeRegions = findCodeRegions(fileContent);
const imports = findImports(fileContent);
if (debugMode) {
logger.debug(`Processing import: ${importPath} -> ${fullPath}`);
}
// Process imports in reverse order to handle indices correctly
for (let i = imports.length - 1; i >= 0; i--) {
const { start, _end, path: importPath } = imports[i];
// Check for circular imports - if we're already processing this file
if (importState.currentFile === fullPath) {
if (debugMode) {
logger.warn(`Circular import detected: ${importPath}`);
}
// Replace the import with a warning comment
processedContent = processedContent.replace(
match[0],
`<!-- Circular import detected: ${importPath} -->`,
);
continue;
}
// Check if we've already processed this file in this import chain
if (importState.processedFiles.has(fullPath)) {
if (debugMode) {
logger.warn(`File already processed in this chain: ${importPath}`);
}
// Replace the import with a warning comment
processedContent = processedContent.replace(
match[0],
`<!-- File already processed: ${importPath} -->`,
);
continue;
}
// Check for potential circular imports by looking at the import chain
if (importState.currentFile) {
const currentFileDir = path.dirname(importState.currentFile);
const potentialCircularPath = path.resolve(currentFileDir, importPath);
if (potentialCircularPath === importState.currentFile) {
if (debugMode) {
logger.warn(`Circular import detected: ${importPath}`);
// Skip if inside a code region
if (
codeRegions.some(
([regionStart, regionEnd]) =>
start >= regionStart && start < regionEnd,
)
) {
continue;
}
// Validate import path
if (
!validateImportPath(importPath, fileBasePath, [projectRoot || ''])
) {
continue;
}
const fullPath = path.resolve(fileBasePath, importPath);
const normalizedFullPath = path.normalize(fullPath);
// Skip if already processed
if (processedFiles.has(normalizedFullPath)) continue;
try {
await fs.access(fullPath);
const importedContent = await fs.readFile(fullPath, 'utf-8');
// Process the imported file
await processFlat(
importedContent,
path.dirname(fullPath),
normalizedFullPath,
depth + 1,
);
} catch (error) {
if (debugMode) {
logger.warn(
`Failed to import ${fullPath}: ${hasMessage(error) ? error.message : 'Unknown error'}`,
);
}
// Continue with other imports even if one fails
}
// Replace the import with a warning comment
processedContent = processedContent.replace(
match[0],
`<!-- Circular import detected: ${importPath} -->`,
);
continue;
}
}
// Start with the root file (current file)
const rootPath = path.normalize(
importState.currentFile || path.resolve(basePath),
);
await processFlat(content, basePath, rootPath, 0);
// Concatenate all unique files in order, Claude-style
const flatContent = flatFiles
.map(
(f) =>
`--- File: ${f.path} ---\n${f.content.trim()}\n--- End of File: ${f.path} ---`,
)
.join('\n\n');
return {
content: flatContent,
importTree: { path: rootPath }, // Tree not meaningful in flat mode
};
}
// --- TREE FORMAT LOGIC (existing) ---
const codeRegions = findCodeRegions(content);
let result = '';
let lastIndex = 0;
const imports: MemoryFile[] = [];
const importsList = findImports(content);
for (const { start, _end, path: importPath } of importsList) {
// Add content before this import
result += content.substring(lastIndex, start);
lastIndex = _end;
// Skip if inside a code region
if (codeRegions.some(([s, e]) => start >= s && start < e)) {
result += `@${importPath}`;
continue;
}
// Validate import path to prevent path traversal attacks
if (!validateImportPath(importPath, basePath, [projectRoot || ''])) {
result += `<!-- Import failed: ${importPath} - Path traversal attempt -->`;
continue;
}
const fullPath = path.resolve(basePath, importPath);
if (importState.processedFiles.has(fullPath)) {
result += `<!-- File already processed: ${importPath} -->`;
continue;
}
try {
// Check if the file exists
await fs.access(fullPath);
// Read the imported file content
const importedContent = await fs.readFile(fullPath, 'utf-8');
if (debugMode) {
logger.debug(`Successfully read imported file: ${fullPath}`);
}
// Recursively process imports in the imported content
const processedImportedContent = await processImports(
importedContent,
const fileContent = await fs.readFile(fullPath, 'utf-8');
// Mark this file as processed for this import chain
const newImportState: ImportState = {
...importState,
processedFiles: new Set(importState.processedFiles),
currentDepth: importState.currentDepth + 1,
currentFile: fullPath,
};
newImportState.processedFiles.add(fullPath);
const imported = await processImports(
fileContent,
path.dirname(fullPath),
debugMode,
{
...importState,
processedFiles: new Set([...importState.processedFiles, fullPath]),
currentDepth: importState.currentDepth + 1,
currentFile: fullPath, // Set the current file being processed
},
newImportState,
projectRoot,
importFormat,
);
// Replace the import statement with the processed content
processedContent = processedContent.replace(
match[0],
`<!-- Imported from: ${importPath} -->\n${processedImportedContent}\n<!-- End of import from: ${importPath} -->`,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
if (debugMode) {
logger.error(`Failed to import ${importPath}: ${errorMessage}`);
result += `<!-- Imported from: ${importPath} -->\n${imported.content}\n<!-- End of import from: ${importPath} -->`;
imports.push(imported.importTree);
} catch (err: unknown) {
let message = 'Unknown error';
if (hasMessage(err)) {
message = err.message;
} else if (typeof err === 'string') {
message = err;
}
// Replace the import with an error comment
processedContent = processedContent.replace(
match[0],
`<!-- Import failed: ${importPath} - ${errorMessage} -->`,
);
logger.error(`Failed to import ${importPath}: ${message}`);
result += `<!-- Import failed: ${importPath} - ${message} -->`;
}
}
// Add any remaining content after the last match
result += content.substring(lastIndex);
return processedContent;
return {
content: result,
importTree: {
path: importState.currentFile || 'unknown',
imports: imports.length > 0 ? imports : undefined,
},
};
}
/**
* Validates import paths to ensure they are safe and within allowed directories
*
* @param importPath - The import path to validate
* @param basePath - The base directory for resolving relative paths
* @param allowedDirectories - Array of allowed directory paths
* @returns Whether the import path is valid
*/
export function validateImportPath(
importPath: string,
basePath: string,
@@ -209,6 +413,8 @@ export function validateImportPath(
return allowedDirectories.some((allowedDir) => {
const normalizedAllowedDir = path.resolve(allowedDir);
return resolvedPath.startsWith(normalizedAllowedDir);
const isSamePath = resolvedPath === normalizedAllowedDir;
const isSubPath = resolvedPath.startsWith(normalizedAllowedDir + path.sep);
return isSamePath || isSubPath;
});
}

View File

@@ -6,7 +6,7 @@
import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest';
import { Content, GoogleGenAI, Models } from '@google/genai';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
import { GeminiClient } from '../core/client.js';
import { Config } from '../config/config.js';
import { checkNextSpeaker, NextSpeakerResponse } from './nextSpeakerChecker.js';
@@ -248,6 +248,6 @@ describe('checkNextSpeaker', () => {
expect(mockGeminiClient.generateJson).toHaveBeenCalled();
const generateJsonCall = (mockGeminiClient.generateJson as Mock).mock
.calls[0];
expect(generateJsonCall[3]).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(generateJsonCall[3]).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
});

View File

@@ -5,7 +5,7 @@
*/
import { Content, SchemaUnion, Type } from '@google/genai';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
import { GeminiClient } from '../core/client.js';
import { GeminiChat } from '../core/geminiChat.js';
import { isFunctionResponse } from './messageInspectors.js';
@@ -14,27 +14,7 @@ const CHECK_PROMPT = `Analyze *only* the content and structure of your immediate
**Decision Rules (apply in order):**
1. **Model Continues:** If your last response explicitly states an immediate next action *you* intend to take (e.g., "Next, I will...", "Now I'll process...", "Moving on to analyze...", indicates an intended tool call that didn't execute), OR if the response seems clearly incomplete (cut off mid-thought without a natural conclusion), then the **'model'** should speak next.
2. **Question to User:** If your last response ends with a direct question specifically addressed *to the user*, then the **'user'** should speak next.
3. **Waiting for User:** If your last response completed a thought, statement, or task *and* does not meet the criteria for Rule 1 (Model Continues) or Rule 2 (Question to User), it implies a pause expecting user input or reaction. In this case, the **'user'** should speak next.
**Output Format:**
Respond *only* in JSON format according to the following schema. Do not include any text outside the JSON structure.
\`\`\`json
{
"type": "object",
"properties": {
"reasoning": {
"type": "string",
"description": "Brief explanation justifying the 'next_speaker' choice based *strictly* on the applicable rule and the content/structure of the preceding turn."
},
"next_speaker": {
"type": "string",
"enum": ["user", "model"],
"description": "Who should speak next based *only* on the preceding turn and the decision rules."
}
},
"required": ["next_speaker", "reasoning"]
}
\`\`\`
`;
3. **Waiting for User:** If your last response completed a thought, statement, or task *and* does not meet the criteria for Rule 1 (Model Continues) or Rule 2 (Question to User), it implies a pause expecting user input or reaction. In this case, the **'user'** should speak next.`;
const RESPONSE_SCHEMA: SchemaUnion = {
type: Type.OBJECT,
@@ -132,7 +112,7 @@ export async function checkNextSpeaker(
contents,
RESPONSE_SCHEMA,
abortSignal,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
)) as unknown as NextSpeakerResponse;
if (

View File

@@ -0,0 +1,214 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { escapePath, unescapePath } from './paths.js';
describe('escapePath', () => {
it('should escape spaces', () => {
expect(escapePath('my file.txt')).toBe('my\\ file.txt');
});
it('should escape tabs', () => {
expect(escapePath('file\twith\ttabs.txt')).toBe('file\\\twith\\\ttabs.txt');
});
it('should escape parentheses', () => {
expect(escapePath('file(1).txt')).toBe('file\\(1\\).txt');
});
it('should escape square brackets', () => {
expect(escapePath('file[backup].txt')).toBe('file\\[backup\\].txt');
});
it('should escape curly braces', () => {
expect(escapePath('file{temp}.txt')).toBe('file\\{temp\\}.txt');
});
it('should escape semicolons', () => {
expect(escapePath('file;name.txt')).toBe('file\\;name.txt');
});
it('should escape ampersands', () => {
expect(escapePath('file&name.txt')).toBe('file\\&name.txt');
});
it('should escape pipes', () => {
expect(escapePath('file|name.txt')).toBe('file\\|name.txt');
});
it('should escape asterisks', () => {
expect(escapePath('file*.txt')).toBe('file\\*.txt');
});
it('should escape question marks', () => {
expect(escapePath('file?.txt')).toBe('file\\?.txt');
});
it('should escape dollar signs', () => {
expect(escapePath('file$name.txt')).toBe('file\\$name.txt');
});
it('should escape backticks', () => {
expect(escapePath('file`name.txt')).toBe('file\\`name.txt');
});
it('should escape single quotes', () => {
expect(escapePath("file'name.txt")).toBe("file\\'name.txt");
});
it('should escape double quotes', () => {
expect(escapePath('file"name.txt')).toBe('file\\"name.txt');
});
it('should escape hash symbols', () => {
expect(escapePath('file#name.txt')).toBe('file\\#name.txt');
});
it('should escape exclamation marks', () => {
expect(escapePath('file!name.txt')).toBe('file\\!name.txt');
});
it('should escape tildes', () => {
expect(escapePath('file~name.txt')).toBe('file\\~name.txt');
});
it('should escape less than and greater than signs', () => {
expect(escapePath('file<name>.txt')).toBe('file\\<name\\>.txt');
});
it('should handle multiple special characters', () => {
expect(escapePath('my file (backup) [v1.2].txt')).toBe(
'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt',
);
});
it('should not double-escape already escaped characters', () => {
expect(escapePath('my\\ file.txt')).toBe('my\\ file.txt');
expect(escapePath('file\\(name\\).txt')).toBe('file\\(name\\).txt');
});
it('should handle escaped backslashes correctly', () => {
// Double backslash (escaped backslash) followed by space should escape the space
expect(escapePath('path\\\\ file.txt')).toBe('path\\\\\\ file.txt');
// Triple backslash (escaped backslash + escaping backslash) followed by space should not double-escape
expect(escapePath('path\\\\\\ file.txt')).toBe('path\\\\\\ file.txt');
// Quadruple backslash (two escaped backslashes) followed by space should escape the space
expect(escapePath('path\\\\\\\\ file.txt')).toBe('path\\\\\\\\\\ file.txt');
});
it('should handle complex escaped backslash scenarios', () => {
// Escaped backslash before special character that needs escaping
expect(escapePath('file\\\\(test).txt')).toBe('file\\\\\\(test\\).txt');
// Multiple escaped backslashes
expect(escapePath('path\\\\\\\\with space.txt')).toBe(
'path\\\\\\\\with\\ space.txt',
);
});
it('should handle paths without special characters', () => {
expect(escapePath('normalfile.txt')).toBe('normalfile.txt');
expect(escapePath('path/to/normalfile.txt')).toBe('path/to/normalfile.txt');
});
it('should handle complex real-world examples', () => {
expect(escapePath('My Documents/Project (2024)/file [backup].txt')).toBe(
'My\\ Documents/Project\\ \\(2024\\)/file\\ \\[backup\\].txt',
);
expect(escapePath('file with $special &chars!.txt')).toBe(
'file\\ with\\ \\$special\\ \\&chars\\!.txt',
);
});
it('should handle empty strings', () => {
expect(escapePath('')).toBe('');
});
it('should handle paths with only special characters', () => {
expect(escapePath(' ()[]{};&|*?$`\'"#!~<>')).toBe(
'\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>',
);
});
});
describe('unescapePath', () => {
it('should unescape spaces', () => {
expect(unescapePath('my\\ file.txt')).toBe('my file.txt');
});
it('should unescape tabs', () => {
expect(unescapePath('file\\\twith\\\ttabs.txt')).toBe(
'file\twith\ttabs.txt',
);
});
it('should unescape parentheses', () => {
expect(unescapePath('file\\(1\\).txt')).toBe('file(1).txt');
});
it('should unescape square brackets', () => {
expect(unescapePath('file\\[backup\\].txt')).toBe('file[backup].txt');
});
it('should unescape curly braces', () => {
expect(unescapePath('file\\{temp\\}.txt')).toBe('file{temp}.txt');
});
it('should unescape multiple special characters', () => {
expect(unescapePath('my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt')).toBe(
'my file (backup) [v1.2].txt',
);
});
it('should handle paths without escaped characters', () => {
expect(unescapePath('normalfile.txt')).toBe('normalfile.txt');
expect(unescapePath('path/to/normalfile.txt')).toBe(
'path/to/normalfile.txt',
);
});
it('should handle all special characters', () => {
expect(
unescapePath(
'\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>',
),
).toBe(' ()[]{};&|*?$`\'"#!~<>');
});
it('should be the inverse of escapePath', () => {
const testCases = [
'my file.txt',
'file(1).txt',
'file[backup].txt',
'My Documents/Project (2024)/file [backup].txt',
'file with $special &chars!.txt',
' ()[]{};&|*?$`\'"#!~<>',
'file\twith\ttabs.txt',
];
testCases.forEach((testCase) => {
expect(unescapePath(escapePath(testCase))).toBe(testCase);
});
});
it('should handle empty strings', () => {
expect(unescapePath('')).toBe('');
});
it('should not affect backslashes not followed by special characters', () => {
expect(unescapePath('file\\name.txt')).toBe('file\\name.txt');
expect(unescapePath('path\\to\\file.txt')).toBe('path\\to\\file.txt');
});
it('should handle escaped backslashes in unescaping', () => {
// Should correctly unescape when there are escaped backslashes
expect(unescapePath('path\\\\\\ file.txt')).toBe('path\\\\ file.txt');
expect(unescapePath('path\\\\\\\\\\ file.txt')).toBe(
'path\\\\\\\\ file.txt',
);
expect(unescapePath('file\\\\\\(test\\).txt')).toBe('file\\\\(test).txt');
});
});

View File

@@ -13,6 +13,13 @@ export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
const TMP_DIR_NAME = 'tmp';
const COMMANDS_DIR_NAME = 'commands';
/**
* Special characters that need to be escaped in file paths for shell compatibility.
* Includes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes,
* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
*/
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/;
/**
* Replaces the home directory with a tilde.
* @param path - The path to tildeify.
@@ -119,26 +126,43 @@ export function makeRelative(
}
/**
* Escapes spaces in a file path.
* Escapes special characters in a file path like macOS terminal does.
* Escapes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes,
* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
*/
export function escapePath(filePath: string): string {
let result = '';
for (let i = 0; i < filePath.length; i++) {
// Only escape spaces that are not already escaped.
if (filePath[i] === ' ' && (i === 0 || filePath[i - 1] !== '\\')) {
result += '\\ ';
const char = filePath[i];
// Count consecutive backslashes before this character
let backslashCount = 0;
for (let j = i - 1; j >= 0 && filePath[j] === '\\'; j--) {
backslashCount++;
}
// Character is already escaped if there's an odd number of backslashes before it
const isAlreadyEscaped = backslashCount % 2 === 1;
// Only escape if not already escaped
if (!isAlreadyEscaped && SHELL_SPECIAL_CHARS.test(char)) {
result += '\\' + char;
} else {
result += filePath[i];
result += char;
}
}
return result;
}
/**
* Unescapes spaces in a file path.
* Unescapes special characters in a file path.
* Removes backslash escaping from shell metacharacters.
*/
export function unescapePath(filePath: string): string {
return filePath.replace(/\\ /g, ' ');
return filePath.replace(
new RegExp(`\\\\([${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`, 'g'),
'$1',
);
}
/**

View File

@@ -6,14 +6,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { retryWithBackoff } from './retry.js';
import { retryWithBackoff, HttpError } from './retry.js';
import { setSimulate429 } from './testUtils.js';
// Define an interface for the error with a status property
interface HttpError extends Error {
status?: number;
}
// Helper to create a mock function that fails a certain number of times
const createFailingFunction = (
failures: number,

View File

@@ -10,6 +10,10 @@ import {
isGenericQuotaExceededError,
} from './quotaErrorDetection.js';
export interface HttpError extends Error {
status?: number;
}
export interface RetryOptions {
maxAttempts: number;
initialDelayMs: number;

View File

@@ -0,0 +1,242 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { openBrowserSecurely } from './secure-browser-launcher.js';
// Create mock function using vi.hoisted
const mockExecFile = vi.hoisted(() => vi.fn());
// Mock modules
vi.mock('node:child_process');
vi.mock('node:util', () => ({
promisify: () => mockExecFile,
}));
describe('secure-browser-launcher', () => {
let originalPlatform: PropertyDescriptor | undefined;
beforeEach(() => {
vi.clearAllMocks();
mockExecFile.mockResolvedValue({ stdout: '', stderr: '' });
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
});
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
});
function setPlatform(platform: string) {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
}
describe('URL validation', () => {
it('should allow valid HTTP URLs', async () => {
setPlatform('darwin');
await openBrowserSecurely('http://example.com');
expect(mockExecFile).toHaveBeenCalledWith(
'open',
['http://example.com'],
expect.any(Object),
);
});
it('should allow valid HTTPS URLs', async () => {
setPlatform('darwin');
await openBrowserSecurely('https://example.com');
expect(mockExecFile).toHaveBeenCalledWith(
'open',
['https://example.com'],
expect.any(Object),
);
});
it('should reject non-HTTP(S) protocols', async () => {
await expect(openBrowserSecurely('file:///etc/passwd')).rejects.toThrow(
'Unsafe protocol',
);
await expect(openBrowserSecurely('javascript:alert(1)')).rejects.toThrow(
'Unsafe protocol',
);
await expect(openBrowserSecurely('ftp://example.com')).rejects.toThrow(
'Unsafe protocol',
);
});
it('should reject invalid URLs', async () => {
await expect(openBrowserSecurely('not-a-url')).rejects.toThrow(
'Invalid URL',
);
await expect(openBrowserSecurely('')).rejects.toThrow('Invalid URL');
});
it('should reject URLs with control characters', async () => {
await expect(
openBrowserSecurely('http://example.com\nmalicious-command'),
).rejects.toThrow('invalid characters');
await expect(
openBrowserSecurely('http://example.com\rmalicious-command'),
).rejects.toThrow('invalid characters');
await expect(
openBrowserSecurely('http://example.com\x00'),
).rejects.toThrow('invalid characters');
});
});
describe('Command injection prevention', () => {
it('should prevent PowerShell command injection on Windows', async () => {
setPlatform('win32');
// The POC from the vulnerability report
const maliciousUrl =
"http://127.0.0.1:8080/?param=example#$(Invoke-Expression([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('Y2FsYy5leGU='))))";
await openBrowserSecurely(maliciousUrl);
// Verify that execFile was called (not exec) and the URL is passed safely
expect(mockExecFile).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-WindowStyle',
'Hidden',
'-Command',
`Start-Process '${maliciousUrl.replace(/'/g, "''")}'`,
],
expect.any(Object),
);
});
it('should handle URLs with special shell characters safely', async () => {
setPlatform('darwin');
const urlsWithSpecialChars = [
'http://example.com/path?param=value&other=$value',
'http://example.com/path#fragment;command',
'http://example.com/$(whoami)',
'http://example.com/`command`',
'http://example.com/|pipe',
'http://example.com/>redirect',
];
for (const url of urlsWithSpecialChars) {
await openBrowserSecurely(url);
// Verify the URL is passed as an argument, not interpreted by shell
expect(mockExecFile).toHaveBeenCalledWith(
'open',
[url],
expect.any(Object),
);
}
});
it('should properly escape single quotes in URLs on Windows', async () => {
setPlatform('win32');
const urlWithSingleQuotes =
"http://example.com/path?name=O'Brien&test='value'";
await openBrowserSecurely(urlWithSingleQuotes);
// Verify that single quotes are escaped by doubling them
expect(mockExecFile).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-WindowStyle',
'Hidden',
'-Command',
`Start-Process 'http://example.com/path?name=O''Brien&test=''value'''`,
],
expect.any(Object),
);
});
});
describe('Platform-specific behavior', () => {
it('should use correct command on macOS', async () => {
setPlatform('darwin');
await openBrowserSecurely('https://example.com');
expect(mockExecFile).toHaveBeenCalledWith(
'open',
['https://example.com'],
expect.any(Object),
);
});
it('should use PowerShell on Windows', async () => {
setPlatform('win32');
await openBrowserSecurely('https://example.com');
expect(mockExecFile).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining([
'-Command',
`Start-Process 'https://example.com'`,
]),
expect.any(Object),
);
});
it('should use xdg-open on Linux', async () => {
setPlatform('linux');
await openBrowserSecurely('https://example.com');
expect(mockExecFile).toHaveBeenCalledWith(
'xdg-open',
['https://example.com'],
expect.any(Object),
);
});
it('should throw on unsupported platforms', async () => {
setPlatform('aix');
await expect(openBrowserSecurely('https://example.com')).rejects.toThrow(
'Unsupported platform',
);
});
});
describe('Error handling', () => {
it('should handle browser launch failures gracefully', async () => {
setPlatform('darwin');
mockExecFile.mockRejectedValueOnce(new Error('Command not found'));
await expect(openBrowserSecurely('https://example.com')).rejects.toThrow(
'Failed to open browser',
);
});
it('should try fallback browsers on Linux', async () => {
setPlatform('linux');
// First call to xdg-open fails
mockExecFile.mockRejectedValueOnce(new Error('Command not found'));
// Second call to gnome-open succeeds
mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' });
await openBrowserSecurely('https://example.com');
expect(mockExecFile).toHaveBeenCalledTimes(2);
expect(mockExecFile).toHaveBeenNthCalledWith(
1,
'xdg-open',
['https://example.com'],
expect.any(Object),
);
expect(mockExecFile).toHaveBeenNthCalledWith(
2,
'gnome-open',
['https://example.com'],
expect.any(Object),
);
});
});
});

View File

@@ -0,0 +1,188 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { platform } from 'node:os';
import { URL } from 'node:url';
const execFileAsync = promisify(execFile);
/**
* Validates that a URL is safe to open in a browser.
* Only allows HTTP and HTTPS URLs to prevent command injection.
*
* @param url The URL to validate
* @throws Error if the URL is invalid or uses an unsafe protocol
*/
function validateUrl(url: string): void {
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch (_error) {
throw new Error(`Invalid URL: ${url}`);
}
// Only allow HTTP and HTTPS protocols
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error(
`Unsafe protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`,
);
}
// Additional validation: ensure no newlines or control characters
// eslint-disable-next-line no-control-regex
if (/[\r\n\x00-\x1f]/.test(url)) {
throw new Error('URL contains invalid characters');
}
}
/**
* Opens a URL in the default browser using platform-specific commands.
* This implementation avoids shell injection vulnerabilities by:
* 1. Validating the URL to ensure it's HTTP/HTTPS only
* 2. Using execFile instead of exec to avoid shell interpretation
* 3. Passing the URL as an argument rather than constructing a command string
*
* @param url The URL to open
* @throws Error if the URL is invalid or if opening the browser fails
*/
export async function openBrowserSecurely(url: string): Promise<void> {
// Validate the URL first
validateUrl(url);
const platformName = platform();
let command: string;
let args: string[];
switch (platformName) {
case 'darwin':
// macOS
command = 'open';
args = [url];
break;
case 'win32':
// Windows - use PowerShell with Start-Process
// This avoids the cmd.exe shell which is vulnerable to injection
command = 'powershell.exe';
args = [
'-NoProfile',
'-NonInteractive',
'-WindowStyle',
'Hidden',
'-Command',
`Start-Process '${url.replace(/'/g, "''")}'`,
];
break;
case 'linux':
case 'freebsd':
case 'openbsd':
// Linux and BSD variants
// Try xdg-open first, fall back to other options
command = 'xdg-open';
args = [url];
break;
default:
throw new Error(`Unsupported platform: ${platformName}`);
}
const options: Record<string, unknown> = {
// Don't inherit parent's environment to avoid potential issues
env: {
...process.env,
// Ensure we're not in a shell that might interpret special characters
SHELL: undefined,
},
// Detach the browser process so it doesn't block
detached: true,
stdio: 'ignore',
};
try {
await execFileAsync(command, args, options);
} catch (error) {
// For Linux, try fallback commands if xdg-open fails
if (
(platformName === 'linux' ||
platformName === 'freebsd' ||
platformName === 'openbsd') &&
command === 'xdg-open'
) {
const fallbackCommands = [
'gnome-open',
'kde-open',
'firefox',
'chromium',
'google-chrome',
];
for (const fallbackCommand of fallbackCommands) {
try {
await execFileAsync(fallbackCommand, [url], options);
return; // Success!
} catch {
// Try next command
continue;
}
}
}
// Re-throw the error if all attempts failed
throw new Error(
`Failed to open browser: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
/**
* Checks if the current environment should attempt to launch a browser.
* This is the same logic as in browser.ts for consistency.
*
* @returns True if the tool should attempt to launch a browser
*/
export function shouldLaunchBrowser(): boolean {
// A list of browser names that indicate we should not attempt to open a
// web browser for the user.
const browserBlocklist = ['www-browser'];
const browserEnv = process.env.BROWSER;
if (browserEnv && browserBlocklist.includes(browserEnv)) {
return false;
}
// Common environment variables used in CI/CD or other non-interactive shells.
if (process.env.CI || process.env.DEBIAN_FRONTEND === 'noninteractive') {
return false;
}
// The presence of SSH_CONNECTION indicates a remote session.
// We should not attempt to launch a browser unless a display is explicitly available
// (checked below for Linux).
const isSSH = !!process.env.SSH_CONNECTION;
// On Linux, the presence of a display server is a strong indicator of a GUI.
if (platform() === 'linux') {
// These are environment variables that can indicate a running compositor on Linux.
const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'];
const hasDisplay = displayVariables.some((v) => !!process.env[v]);
if (!hasDisplay) {
return false;
}
}
// If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser.
// The Linux case is handled above (it's allowed if DISPLAY is set).
if (isSSH && platform() !== 'linux') {
return false;
}
// For non-Linux OSes, we generally assume a GUI is available
// unless other signals (like SSH) suggest otherwise.
return true;
}

View File

@@ -11,7 +11,7 @@ import {
GenerateContentResponse,
} from '@google/genai';
import { GeminiClient } from '../core/client.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
import { getResponseText, partToString } from './partUtils.js';
/**
@@ -86,7 +86,7 @@ export async function summarizeToolOutput(
contents,
toolOutputSummarizerConfig,
abortSignal,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
)) as unknown as GenerateContentResponse;
return getResponseText(parsedResponse) || textToSummarize;
} catch (error) {

View File

@@ -0,0 +1,283 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { WorkspaceContext } from './workspaceContext.js';
vi.mock('fs');
describe('WorkspaceContext', () => {
let workspaceContext: WorkspaceContext;
// Use path module to create platform-agnostic paths
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
const mockExistingDir = path.resolve(
path.sep,
'home',
'user',
'other-project',
);
const mockNonExistentDir = path.resolve(
path.sep,
'home',
'user',
'does-not-exist',
);
const mockSymlinkDir = path.resolve(path.sep, 'home', 'user', 'symlink');
const mockRealPath = path.resolve(path.sep, 'home', 'user', 'real-directory');
beforeEach(() => {
vi.resetAllMocks();
// Mock fs.existsSync
vi.mocked(fs.existsSync).mockImplementation((path) => {
const pathStr = path.toString();
return (
pathStr === mockCwd ||
pathStr === mockExistingDir ||
pathStr === mockSymlinkDir ||
pathStr === mockRealPath
);
});
// Mock fs.statSync
vi.mocked(fs.statSync).mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr === mockNonExistentDir) {
throw new Error('ENOENT');
}
return {
isDirectory: () => true,
} as fs.Stats;
});
// Mock fs.realpathSync
vi.mocked(fs.realpathSync).mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr === mockSymlinkDir) {
return mockRealPath;
}
return pathStr;
});
});
describe('initialization', () => {
it('should initialize with a single directory (cwd)', () => {
workspaceContext = new WorkspaceContext(mockCwd);
const directories = workspaceContext.getDirectories();
expect(directories).toHaveLength(1);
expect(directories[0]).toBe(mockCwd);
});
it('should validate and resolve directories to absolute paths', () => {
const absolutePath = path.join(mockCwd, 'subdir');
vi.mocked(fs.existsSync).mockImplementation(
(p) => p === mockCwd || p === absolutePath,
);
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
workspaceContext = new WorkspaceContext(mockCwd, [absolutePath]);
const directories = workspaceContext.getDirectories();
expect(directories).toContain(absolutePath);
});
it('should reject non-existent directories', () => {
expect(() => {
new WorkspaceContext(mockCwd, [mockNonExistentDir]);
}).toThrow('Directory does not exist');
});
it('should handle empty initialization', () => {
workspaceContext = new WorkspaceContext(mockCwd, []);
const directories = workspaceContext.getDirectories();
expect(directories).toHaveLength(1);
expect(directories[0]).toBe(mockCwd);
});
});
describe('adding directories', () => {
beforeEach(() => {
workspaceContext = new WorkspaceContext(mockCwd);
});
it('should add valid directories', () => {
workspaceContext.addDirectory(mockExistingDir);
const directories = workspaceContext.getDirectories();
expect(directories).toHaveLength(2);
expect(directories).toContain(mockExistingDir);
});
it('should resolve relative paths to absolute', () => {
// Since we can't mock path.resolve, we'll test with absolute paths
workspaceContext.addDirectory(mockExistingDir);
const directories = workspaceContext.getDirectories();
expect(directories).toContain(mockExistingDir);
});
it('should reject non-existent directories', () => {
expect(() => {
workspaceContext.addDirectory(mockNonExistentDir);
}).toThrow('Directory does not exist');
});
it('should prevent duplicate directories', () => {
workspaceContext.addDirectory(mockExistingDir);
workspaceContext.addDirectory(mockExistingDir);
const directories = workspaceContext.getDirectories();
expect(directories.filter((d) => d === mockExistingDir)).toHaveLength(1);
});
it('should handle symbolic links correctly', () => {
workspaceContext.addDirectory(mockSymlinkDir);
const directories = workspaceContext.getDirectories();
expect(directories).toContain(mockRealPath);
expect(directories).not.toContain(mockSymlinkDir);
});
});
describe('path validation', () => {
beforeEach(() => {
workspaceContext = new WorkspaceContext(mockCwd, [mockExistingDir]);
});
it('should accept paths within workspace directories', () => {
const validPath1 = path.join(mockCwd, 'src', 'file.ts');
const validPath2 = path.join(mockExistingDir, 'lib', 'module.js');
expect(workspaceContext.isPathWithinWorkspace(validPath1)).toBe(true);
expect(workspaceContext.isPathWithinWorkspace(validPath2)).toBe(true);
});
it('should reject paths outside workspace', () => {
const invalidPath = path.resolve(
path.dirname(mockCwd),
'outside-workspace',
'file.txt',
);
expect(workspaceContext.isPathWithinWorkspace(invalidPath)).toBe(false);
});
it('should resolve symbolic links before validation', () => {
const symlinkPath = path.join(mockCwd, 'symlink-file');
const realPath = path.join(mockCwd, 'real-file');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => {
if (p === symlinkPath) {
return realPath;
}
return p.toString();
});
expect(workspaceContext.isPathWithinWorkspace(symlinkPath)).toBe(true);
});
it('should handle nested directories correctly', () => {
const nestedPath = path.join(
mockCwd,
'deeply',
'nested',
'path',
'file.txt',
);
expect(workspaceContext.isPathWithinWorkspace(nestedPath)).toBe(true);
});
it('should handle edge cases (root, parent references)', () => {
const rootPath = '/';
const parentPath = path.dirname(mockCwd);
expect(workspaceContext.isPathWithinWorkspace(rootPath)).toBe(false);
expect(workspaceContext.isPathWithinWorkspace(parentPath)).toBe(false);
});
it('should handle non-existent paths correctly', () => {
const nonExistentPath = path.join(mockCwd, 'does-not-exist.txt');
vi.mocked(fs.existsSync).mockImplementation((p) => p !== nonExistentPath);
// Should still validate based on path structure
expect(workspaceContext.isPathWithinWorkspace(nonExistentPath)).toBe(
true,
);
});
});
describe('getDirectories', () => {
it('should return a copy of directories array', () => {
workspaceContext = new WorkspaceContext(mockCwd);
const dirs1 = workspaceContext.getDirectories();
const dirs2 = workspaceContext.getDirectories();
expect(dirs1).not.toBe(dirs2); // Different array instances
expect(dirs1).toEqual(dirs2); // Same content
});
});
describe('symbolic link security', () => {
beforeEach(() => {
workspaceContext = new WorkspaceContext(mockCwd);
});
it('should follow symlinks but validate resolved path', () => {
const symlinkInsideWorkspace = path.join(mockCwd, 'link-to-subdir');
const resolvedInsideWorkspace = path.join(mockCwd, 'subdir');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => {
if (p === symlinkInsideWorkspace) {
return resolvedInsideWorkspace;
}
return p.toString();
});
expect(
workspaceContext.isPathWithinWorkspace(symlinkInsideWorkspace),
).toBe(true);
});
it('should prevent sandbox escape via symlinks', () => {
const symlinkEscape = path.join(mockCwd, 'escape-link');
const resolvedOutside = path.resolve(mockCwd, '..', 'outside-file');
vi.mocked(fs.existsSync).mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr === symlinkEscape ||
pathStr === resolvedOutside ||
pathStr === mockCwd
);
});
vi.mocked(fs.realpathSync).mockImplementation((p) => {
if (p.toString() === symlinkEscape) {
return resolvedOutside;
}
return p.toString();
});
vi.mocked(fs.statSync).mockImplementation(
(p) =>
({
isDirectory: () => p.toString() !== resolvedOutside,
}) as fs.Stats,
);
workspaceContext = new WorkspaceContext(mockCwd);
expect(workspaceContext.isPathWithinWorkspace(symlinkEscape)).toBe(false);
});
it('should handle circular symlinks', () => {
const circularLink = path.join(mockCwd, 'circular');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation(() => {
throw new Error('ELOOP: too many symbolic links encountered');
});
// Should handle the error gracefully
expect(workspaceContext.isPathWithinWorkspace(circularLink)).toBe(false);
});
});
});

View File

@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
/**
* WorkspaceContext manages multiple workspace directories and validates paths
* against them. This allows the CLI to operate on files from multiple directories
* in a single session.
*/
export class WorkspaceContext {
private directories: Set<string>;
/**
* Creates a new WorkspaceContext with the given initial directory and optional additional directories.
* @param initialDirectory The initial working directory (usually cwd)
* @param additionalDirectories Optional array of additional directories to include
*/
constructor(initialDirectory: string, additionalDirectories: string[] = []) {
this.directories = new Set<string>();
this.addDirectoryInternal(initialDirectory);
for (const dir of additionalDirectories) {
this.addDirectoryInternal(dir);
}
}
/**
* Adds a directory to the workspace.
* @param directory The directory path to add (can be relative or absolute)
* @param basePath Optional base path for resolving relative paths (defaults to cwd)
*/
addDirectory(directory: string, basePath: string = process.cwd()): void {
this.addDirectoryInternal(directory, basePath);
}
/**
* Internal method to add a directory with validation.
*/
private addDirectoryInternal(
directory: string,
basePath: string = process.cwd(),
): void {
const absolutePath = path.isAbsolute(directory)
? directory
: path.resolve(basePath, directory);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Directory does not exist: ${absolutePath}`);
}
const stats = fs.statSync(absolutePath);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${absolutePath}`);
}
let realPath: string;
try {
realPath = fs.realpathSync(absolutePath);
} catch (_error) {
throw new Error(`Failed to resolve path: ${absolutePath}`);
}
this.directories.add(realPath);
}
/**
* Gets a copy of all workspace directories.
* @returns Array of absolute directory paths
*/
getDirectories(): readonly string[] {
return Array.from(this.directories);
}
/**
* Checks if a given path is within any of the workspace directories.
* @param pathToCheck The path to validate
* @returns True if the path is within the workspace, false otherwise
*/
isPathWithinWorkspace(pathToCheck: string): boolean {
try {
const absolutePath = path.resolve(pathToCheck);
let resolvedPath = absolutePath;
if (fs.existsSync(absolutePath)) {
try {
resolvedPath = fs.realpathSync(absolutePath);
} catch (_error) {
return false;
}
}
for (const dir of this.directories) {
if (this.isPathWithinRoot(resolvedPath, dir)) {
return true;
}
}
return false;
} catch (_error) {
return false;
}
}
/**
* Checks if a path is within a given root directory.
* @param pathToCheck The absolute path to check
* @param rootDirectory The absolute root directory
* @returns True if the path is within the root directory, false otherwise
*/
private isPathWithinRoot(
pathToCheck: string,
rootDirectory: string,
): boolean {
const relative = path.relative(rootDirectory, pathToCheck);
return (
!relative.startsWith(`..${path.sep}`) &&
relative !== '..' &&
!path.isAbsolute(relative)
);
}
}