Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 154 additions & 16 deletions packages/core/src/utils/memoryImportProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,27 +180,165 @@ describe('memoryImportProcessor', () => {
);
});

it('should handle circular imports', async () => {
const content = 'Content @./circular.md more content';
const basePath = testPath('test', 'path');
const circularContent = 'Circular @./main.md content';
it('should handle circular imports gracefully', async () => {
const content = '@a.md';
const projectRoot = testPath('test', 'project');
const basePath = testPath(projectRoot, 'src');

// Setup circular import: a.md -> b.md -> a.md
mockedFs.access.mockResolvedValue(undefined);
mockedFs.readFile.mockResolvedValue(circularContent);
mockedFs.readFile.mockImplementation(async (p) => {
const filePath = p as string;
if (filePath.endsWith('a.md')) {
return '@b.md';
}
if (filePath.endsWith('b.md')) {
return '@a.md';
}
return '';
});

// Set up the import state to simulate we're already processing main.md
const importState = {
processedFiles: new Set<string>(),
maxDepth: 10,
currentDepth: 0,
currentFile: testPath('test', 'path', 'main.md'), // Simulate we're processing main.md
};
const result = await processImports(
content,
basePath,
true,
undefined,
projectRoot,
);

const result = await processImports(content, basePath, true, importState);
expect(result.content).toContain('<!-- File already processed: a.md -->');
});

// The circular import should be detected when processing the nested import
expect(result.content).toContain(
'<!-- File already processed: ./main.md -->',
it('should not treat npm-style packages in code blocks as imports', async () => {
const content = [
'* **Frontend:** `vitest` is used for testing. Run with `pnpm -F @google-cloud-pulse/frontend test`.',
'* **Backend:** `jest` is used for testing. Run with `pnpm -F @google-cloud-pulse/backend test`.',
].join('\n');
const projectRoot = testPath('test', 'project');
const basePath = testPath(projectRoot, 'src');

const result = await processImports(
content,
basePath,
true,
undefined,
projectRoot,
);

// No imports should be processed
expect(mockedFs.readFile).not.toHaveBeenCalled();

// The original content should be unchanged
expect(result.content).toBe(content);

// No error messages should be logged
expect(console.error).not.toHaveBeenCalled();
});

it('should not treat python decorators in code blocks as imports', async () => {
const content = [
'```python',
'@app.route("/api")',
'def my_api():',
' return "OK",',
'```',
].join('\n');
const projectRoot = testPath('test', 'project');
const basePath = testPath(projectRoot, 'src');

const result = await processImports(
content,
basePath,
true,
undefined,
projectRoot,
);

// No imports should be processed
expect(mockedFs.readFile).not.toHaveBeenCalled();

// The original content should be unchanged
expect(result.content).toBe(content);

// No error messages should be logged
expect(console.error).not.toHaveBeenCalled();
});

it('should not treat email addresses as imports', async () => {
const content = 'Contact us at [email protected]';
const projectRoot = testPath('test', 'project');
const basePath = testPath(projectRoot, 'src');

const result = await processImports(
content,
basePath,
true,
undefined,
projectRoot,
);

// No imports should be processed
expect(mockedFs.readFile).not.toHaveBeenCalled();

// The original content should be unchanged
expect(result.content).toBe(content);

// No error messages should be logged
expect(console.error).not.toHaveBeenCalled();
});

it('should not treat decorators as imports', async () => {
const content = 'This is a decorator @my-decorator';
const projectRoot = testPath('test', 'project');
const basePath = testPath(projectRoot, 'src');

const result = await processImports(
content,
basePath,
true,
undefined,
projectRoot,
);

// No imports should be processed
expect(mockedFs.readFile).not.toHaveBeenCalled();

// The original content should be unchanged
expect(result.content).toBe(content);

// No error messages should be logged
expect(console.error).not.toHaveBeenCalled();
});

it('should handle subdirectory imports with extensions but ignore those without', async () => {
const content =
'Import with extension: @foo/bar.md and without: @foo/bar';
const projectRoot = testPath('test', 'project');
const basePath = testPath(projectRoot, 'src');
const importedContent = 'Subdirectory content';

mockedFs.access.mockResolvedValue(undefined);
mockedFs.readFile.mockResolvedValueOnce(importedContent);

const result = await processImports(
content,
basePath,
true,
undefined,
projectRoot,
);

// Verify the valid import was processed
expect(result.content).toContain(importedContent);
expect(result.content).toContain('<!-- Imported from: foo/bar.md -->');

// Verify the invalid import was ignored
expect(result.content).toContain('@foo/bar');
expect(result.content).not.toContain('<!-- Imported from: foo/bar -->');
expect(mockedFs.readFile).toHaveBeenCalledTimes(1);
expect(mockedFs.readFile).toHaveBeenCalledWith(
path.resolve(basePath, 'foo/bar.md'),
'utf-8',
);
});

Expand Down
27 changes: 12 additions & 15 deletions packages/core/src/utils/memoryImportProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,18 @@ function findImports(
// 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]))
) {
// A valid import path is one of the following:
// 1. An explicit relative or absolute path (eg: ./.gemini/FILE.md).
// 2. A path with or wihout slashes that has a file extension in the final segment
// (e.g., @folder/file.md), to distinguish from npm packages.
const hasExtension = path.basename(importPath).includes('.');
const isValid =
importPath.startsWith('./') ||
importPath.startsWith('../') ||
importPath.startsWith('/') ||
hasExtension;

if (importPath.length > 0 && isValid) {
imports.push({
start: i,
_end: j,
Expand All @@ -142,14 +147,6 @@ 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);
Expand Down