fix(extraction): a file over the size limit is never read (#1910)

The size gate ran after `readFile`, so a committed video or blob fixture was
decoded in full — 3.4 GB of RSS for a 400 MB file, `Invalid string length`
past ~512 MB — only to be stored as skipped. It was then read again by every
pass that scans files by name: the framework detectors' `readFile` and the
resolver's cached reader, three full passes on the reporter's fixtures.

Stat first, everywhere a source file is read by path: the batch reader and
`extractFile` store an oversize file with a size stamp in place of its
content, change detection hashes the same stamp (so a same-size rewrite of a
file nothing is indexed from is not a change, while crossing the limit in
either direction is), and both by-name readers return null above the limit.
The limit moves to `src/file-limits.ts` so the three sites share one number.

strace on a sparse 400 MB `.ts`: 153 603 reads before, 0 after; indexing it
next to a real file costs no measurable RSS. Stacked on #1914, which shares
the batch-reader hunk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-09-23 14:42:06 +00:00
committed by Forge
co-authored by Claude Fable 5.1
parent 3111045f6e
commit cf66c2148f
5 changed files with 138 additions and 14 deletions
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { oversizeStamp, hashContent } from '../src/extraction';
/**
* A file over the size limit is stored as skipped without ever being read
* (#1910): committed video/blob fixtures used to be decoded in full — and
* hashed — only to be discarded, costing their size in RSS per file.
*/
describe('oversize files are stat-gated, never read (#1910)', () => {
let dir: string;
afterEach(() => { if (dir) fs.rmSync(dir, { recursive: true, force: true }); });
const big = (bytes: number, fill = 0x41) => Buffer.alloc(bytes, fill);
it('indexes the neighbours, records the oversize file as skipped with a size-stamp hash, and does not decode it', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-oversize-'));
fs.writeFileSync(path.join(dir, 'app.ts'), 'export function alpha() { return beta(); }\nexport function beta() { return 1; }\n');
// 1 MB + 1: over the limit; invalid UTF-8 (0xFF) so any decode would be visible as replacement chars.
fs.writeFileSync(path.join(dir, 'blob.ts'), big(1024 * 1024 + 1, 0xff));
const cg = await CodeGraph.init(dir, { index: true });
try {
expect(cg.getNodesByKind('function').map(n => n.name).sort()).toEqual(['alpha', 'beta']);
const rec = cg.getFiles().find(f => f.path === 'blob.ts');
expect(rec).toBeDefined();
expect(rec!.contentHash).toBe(hashContent(oversizeStamp(1024 * 1024 + 1)));
// Nothing from the blob reached the graph.
expect(cg.getNodesInFile('blob.ts').filter(n => n.kind !== 'file')).toEqual([]);
// Change detection agrees with what was stored: nothing pending.
expect(cg.getChangedFiles()).toEqual({ added: [], modified: [], removed: [] });
// A same-size rewrite is not a change (nothing about it is indexed)...
fs.writeFileSync(path.join(dir, 'blob.ts'), big(1024 * 1024 + 1, 0xfe));
expect(cg.getChangedFiles().modified).toEqual([]);
// ...crossing the limit is: the file becomes ordinary source.
fs.writeFileSync(path.join(dir, 'blob.ts'), 'export const gamma = 3;\n');
expect(cg.getChangedFiles().modified).toEqual(['blob.ts']);
await cg.sync();
expect(cg.getNodesInFile('blob.ts').some(n => n.name === 'gamma')).toBe(true);
// ...and growing back over it is a change too, stored as the stamp again.
fs.writeFileSync(path.join(dir, 'blob.ts'), big(2 * 1024 * 1024, 0xff));
expect(cg.getChangedFiles().modified).toEqual(['blob.ts']);
await cg.sync();
expect(cg.getFiles().find(f => f.path === 'blob.ts')!.contentHash).toBe(hashContent(oversizeStamp(2 * 1024 * 1024)));
expect(cg.getNodesInFile('blob.ts').some(n => n.name === 'gamma')).toBe(false);
} finally {
cg.close();
}
});
it('a 400 MB sparse fixture indexes in well under a second and without growing the heap by its size', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-oversize-big-'));
fs.writeFileSync(path.join(dir, 'ok.ts'), 'export const one = 1;\n');
// Sparse: occupies no disk, but stat() reports 400 MB — a read would decode all of it.
const fd = fs.openSync(path.join(dir, 'huge.ts'), 'w');
fs.ftruncateSync(fd, 400 * 1024 * 1024);
fs.closeSync(fd);
// Warm the engine on a sibling project first, so the grammar and worker
// start-up cost is not mistaken for the file being read.
const warm = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-oversize-warm-'));
fs.writeFileSync(path.join(warm, 'w.ts'), 'export const w = 1;\n');
(await CodeGraph.init(warm, { index: true })).close();
fs.rmSync(warm, { recursive: true, force: true });
const before = process.memoryUsage().rss;
const t0 = Date.now();
const cg = await CodeGraph.init(dir, { index: true });
try {
expect(Date.now() - t0).toBeLessThan(5000);
// Reading 400 MB would show as at least that much RSS; the stamp shows as none.
expect(process.memoryUsage().rss - before).toBeLessThan(100 * 1024 * 1024);
expect(cg.getFiles().map(f => f.path).sort()).toEqual(['huge.ts', 'ok.ts']);
expect(cg.getChangedFiles()).toEqual({ added: [], modified: [], removed: [] });
} finally {
cg.close();
}
});
});