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
100 changes: 100 additions & 0 deletions __tests__/resolution-oversized-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Resolution-side oversized-file guard.
*
* Pins the contract that reference resolution treats files larger than
* extraction's MAX_FILE_SIZE exactly like extraction does: invisible.
* Asset imports (`import video from "./intro.mp4"`, aliased or relative)
* hand the resolver a verbatim path to a file extraction never parsed —
* it can't contain resolvable symbols, but the import/re-export chase
* used to `readFileSync` it whole, decode it as UTF-8, and regex-scan
* it. On a real project a single ~240 MB video import OOM'd an 8 GB heap
* at "Resolving refs" ~65%, killing every `codegraph init`.
*/
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';

beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});

describe('resolution skips oversized import targets', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});

it('does not chase re-exports through a barrel extraction skipped for size', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-bigbarrel-'));
fs.mkdirSync(path.join(tmpDir, 'src'));

fs.writeFileSync(
path.join(tmpDir, 'src/real.ts'),
'export function play(): number {\n return 1;\n}\n'
);

// A syntactically valid barrel pushed over MAX_FILE_SIZE by padding.
// Extraction skips it (too big), so resolution must not read it either
// — chasing its re-export would resolve through a file the graph
// doesn't contain, and reading arbitrarily large targets is the OOM.
const padding = `// ${'x'.repeat(120)}\n`.repeat(9000); // ~1.1 MB
fs.writeFileSync(
path.join(tmpDir, 'src/barrel.ts'),
`export { play as video } from "./real";\n${padding}`
);

fs.writeFileSync(
path.join(tmpDir, 'src/app.ts'),
'import { video } from "./barrel";\n' +
'export function run(): number {\n' +
' return video();\n' +
'}\n'
);

const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

// Sanity: the small files are in the graph, the oversized barrel is not.
const fns = cg.getNodesByKind('function');
const play = fns.find((n) => n.name === 'play');
const run = fns.find((n) => n.name === 'run');
expect(play).toBeDefined();
expect(run).toBeDefined();
expect(cg.getNodesInFile('src/barrel.ts')).toHaveLength(0);

// The chase must stop at the oversized barrel: no call edge may reach
// play() — resolving run()'s `video()` there requires reading
// barrel.ts's content. (The structural `contains` edge from real.ts
// itself is expected.)
const playCalls = cg.getIncomingEdges(play!.id).filter((e) => e.kind === 'calls');
expect(playCalls).toHaveLength(0);
});

it('indexes a project importing a >1MB binary asset without dying', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-bigasset-'));
fs.mkdirSync(path.join(tmpDir, 'src'));

// Pseudo-binary garbage over MAX_FILE_SIZE. (The real-world trigger
// was a 240 MB .mp4; the guard threshold is what matters, not scale.)
fs.writeFileSync(path.join(tmpDir, 'src/intro.mp4'), Buffer.alloc(2 * 1024 * 1024, 0xfe));

fs.writeFileSync(
path.join(tmpDir, 'src/app.ts'),
'import video from "./intro.mp4";\n' +
'export function play(): string {\n' +
' return video;\n' +
'}\n'
);

const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const fns = cg.getNodesByKind('function');
expect(fns.find((n) => n.name === 'play')).toBeDefined();
});
});
22 changes: 22 additions & 0 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ const PHP_PROP_SHAPE = /^this->\w+\.\w+$/;
* caches) when tuning for very large or very small projects.
*/
const DEFAULT_CACHE_LIMIT = 5_000;

/**
* Skip resolution-side file reads above this size (bytes). Matches
* extraction's MAX_FILE_SIZE: anything larger was never parsed into the
* graph, so its content can't answer an import/re-export chase — but a
* `import intro from "./intro.mp4"` ref would otherwise pull the whole
* asset into the V8 heap and OOM the index (a single ~240 MB binary is
* enough to kill an 8 GB heap once decoded and regex-scanned).
*/
const MAX_RESOLUTION_FILE_SIZE = 1024 * 1024;
function resolveCacheLimit(): number {
const raw = process.env.CODEGRAPH_RESOLVER_CACHE_SIZE;
if (!raw) return DEFAULT_CACHE_LIMIT;
Expand Down Expand Up @@ -381,6 +391,18 @@ export class ReferenceResolver {
}
const fullPath = path.join(this.projectRoot, filePath);
try {
// Mirror extraction's MAX_FILE_SIZE guard. Resolution follows import
// targets verbatim, so `import video from "./intro.mp4"` (or any large
// asset/bundle an alias resolves to) lands here — extraction never
// indexed a file this large, so it can't contain resolvable symbols or
// re-exports, but decoding hundreds of MB as UTF-8 and regex-scanning
// it for re-exports OOMs the whole index at "Resolving refs".
const stats = fs.statSync(fullPath);
if (stats.size > MAX_RESOLUTION_FILE_SIZE) {
logDebug('Skipping oversized file for resolution', { filePath, size: stats.size });
this.fileCache.set(filePath, null);
return null;
}
const content = fs.readFileSync(fullPath, 'utf-8');
this.fileCache.set(filePath, content);
return content;
Expand Down