diff --git a/CHANGELOG.md b/CHANGELOG.md
index f44be8edd..f597cfc54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
+- Resolution no longer reads oversized dependency archives such as HarmonyOS `.har` packages as source text, preventing a single package target from exhausting the JavaScript heap during indexing or sync.
+- Dynamic-dispatch analysis no longer repeatedly copies every source prefix while scanning match-dense files, avoiding quadratic work and excessive peak memory during the final resolution pass.
- Naming a file by its path in a `codegraph_explore` query now works reliably: the path is resolved against the index and that file is guaranteed a place at the top of the answer. Previously the path was broken into fragments — bracketed route segments like SvelteKit's `[id]` made this worst — and pieces like `page` or `runs` matched every sibling file, so the file you actually named could be crowded out of the answer entirely. A path that doesn't match any indexed file is now called out instead of silently ignored.
- Plainly-worded `codegraph_explore` questions now find camelCase code: a query like "auto-scroll to bottom" can reach a function named `scrollFeedToBottom`, because query words are matched against the words inside identifiers, not just whole names.
- Variables and constants now count when `codegraph_explore` picks its starting symbols, so state held in plain variables — `$state`-style variables in Svelte, for example — no longer gets overlooked.
diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts
index 292658822..c1bbc36e3 100644
--- a/__tests__/extraction.test.ts
+++ b/__tests__/extraction.test.ts
@@ -4,13 +4,13 @@
* Tests for the tree-sitter extraction system.
*/
-import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { extractFromSource, scanDirectory, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore } from '../src/extraction';
-import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars';
+import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile, getParser } from '../src/extraction/grammars';
import { stripCppTemplateArgs, blankCppExportMacros, blankCppInlineMacros, blankMetalAttributes, blankCudaConstructs, blankCppAnnotationMacroCalls, blankCppApiPrefixMacros, blankCppInlineAnnotationMacros, blankCLeadingAttrMacros, recoverMangledCppName } from '../src/extraction/languages/c-cpp';
import { normalizePath } from '../src/utils';
@@ -9469,6 +9469,22 @@ import foo.cfm;
`;
+ it('releases the tag parser tree after extraction', () => {
+ const parser = getParser('cfml');
+ expect(parser).toBeDefined();
+ const sample = parser!.parse('');
+ expect(sample).toBeDefined();
+ const treePrototype = Object.getPrototypeOf(sample!);
+ sample!.delete();
+ const deleteSpy = vi.spyOn(treePrototype, 'delete');
+ try {
+ extractFromSource('TagStyle.cfc', '');
+ expect(deleteSpy).toHaveBeenCalledTimes(1);
+ } finally {
+ deleteSpy.mockRestore();
+ }
+ });
+
it('should name the component from the file name when the tag has no name attribute', () => {
const result = extractFromSource('TagStyle.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts
index 5379c97c3..fa83768ff 100644
--- a/__tests__/graph.test.ts
+++ b/__tests__/graph.test.ts
@@ -11,6 +11,7 @@ import * as os from 'os';
import CodeGraph from '../src/index';
import { Node, Edge } from '../src/types';
import { GraphTraverser } from '../src/graph/traversal';
+import { ToolHandler } from '../src/mcp/tools';
describe('Graph Queries', () => {
let testDir: string;
@@ -535,6 +536,37 @@ function tGraph(nodes: Node[], edges: Edge[]): GraphTraverser {
}
describe('Traversal edge-completeness & limits (#1086–#1090)', () => {
+ it('findPath keeps shortest-path order without duplicate frontier entries', () => {
+ const nodes = ['A', 'B', 'C', 'D', 'E'].map((id) => tNode(id));
+ const edges: Edge[] = [
+ { source: 'A', target: 'B', kind: 'calls', line: 1 },
+ { source: 'A', target: 'B', kind: 'references', line: 2 },
+ { source: 'A', target: 'C', kind: 'calls', line: 3 },
+ { source: 'B', target: 'D', kind: 'calls', line: 4 },
+ { source: 'C', target: 'D', kind: 'calls', line: 5 },
+ { source: 'D', target: 'E', kind: 'calls', line: 6 },
+ ];
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ const batches: string[][] = [];
+ const q = {
+ getNodeById: (id: string) => byId.get(id) ?? null,
+ getNodesByIds: (ids: readonly string[]) => {
+ batches.push([...ids]);
+ expect(new Set(ids).size).toBe(ids.length);
+ return new Map(ids.flatMap((id) => {
+ const node = byId.get(id);
+ return node ? [[id, node] as const] : [];
+ }));
+ },
+ getOutgoingEdges: (source: string) => edges.filter((e) => e.source === source),
+ };
+
+ const path = new GraphTraverser(q as never).findPath('A', 'E');
+ expect(path?.map((step) => step.node.id)).toEqual(['A', 'B', 'D', 'E']);
+ expect(path?.map((step) => step.edge?.line ?? null)).toEqual([null, 1, 4, 6]);
+ expect(batches[0]).toEqual(['B', 'C']);
+ });
+
it('traverseBFS keeps every parallel edge to the same target (#1090)', () => {
// A reaches B via both `calls` and `references` — two distinct edges.
const edges: Edge[] = [
@@ -612,4 +644,27 @@ describe('Traversal edge-completeness & limits (#1086–#1090)', () => {
// The regression: this direct dependency edge used to vanish.
expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'P' && e.kind === 'calls')).toBe(true);
});
+
+ it('getImpactRadius stops at node/edge budgets and marks truncation', () => {
+ const dependents = ['B', 'C', 'D', 'E', 'F'];
+ const nodes = [tNode('A'), ...dependents.map((id) => tNode(id))];
+ const edges: Edge[] = dependents.map((source) => ({ source, target: 'A', kind: 'calls' }));
+ const sub = tGraph(nodes, edges).getImpactRadius('A', 2, { maxNodes: 3, maxEdges: 2 });
+
+ expect(sub.nodes.size).toBe(3);
+ expect(sub.edges).toHaveLength(2);
+ expect(sub.truncated).toBe(true);
+ expect(sub.edges.every((edge) => sub.nodes.has(edge.source) && sub.nodes.has(edge.target))).toBe(true);
+ });
+
+ it('surfaces impact truncation explicitly in MCP output', () => {
+ const formatted = (new ToolHandler(null) as any).formatImpact('A', {
+ nodes: new Map([['A', tNode('A')]]),
+ edges: [],
+ roots: ['A'],
+ truncated: true,
+ });
+ expect(formatted).toMatch(/truncated at safety limit/i);
+ expect(formatted).toMatch(/reduce `depth`/i);
+ });
});
diff --git a/__tests__/integration/lru-cache.test.ts b/__tests__/integration/lru-cache.test.ts
index 8156760ae..56accf326 100644
--- a/__tests__/integration/lru-cache.test.ts
+++ b/__tests__/integration/lru-cache.test.ts
@@ -82,6 +82,44 @@ describe('LRUCache', () => {
expect(() => new LRUCache(NaN)).toThrow();
});
+ it('evicts by retained weight as well as entry count', () => {
+ const cache = new LRUCache(100, {
+ maxWeight: 10,
+ weightOf: (value) => value.length,
+ });
+ cache.set('a', '1234');
+ cache.set('b', '5678');
+ expect(cache.get('a')).toBe('1234'); // refresh a; b is now oldest
+ cache.set('c', '9012');
+ expect(cache.get('b')).toBeUndefined();
+ expect(cache.get('a')).toBe('1234');
+ expect(cache.get('c')).toBe('9012');
+ });
+
+ it('does not retain a single entry larger than the weight budget', () => {
+ const cache = new LRUCache(10, {
+ maxWeight: 4,
+ weightOf: (value) => value.length,
+ });
+ cache.set('too-large', '12345');
+ expect(cache.size).toBe(0);
+ });
+
+ it('updates weight accounting on replacement and clear', () => {
+ const cache = new LRUCache(10, {
+ maxWeight: 6,
+ weightOf: (value) => value.length,
+ });
+ cache.set('a', '12345');
+ cache.set('a', '1');
+ cache.set('b', '23456');
+ expect(cache.get('a')).toBe('1');
+ expect(cache.get('b')).toBe('23456');
+ cache.clear();
+ cache.set('c', '123456');
+ expect(cache.get('c')).toBe('123456');
+ });
+
it('stays bounded under heavy churn (regression for OOM scenario)', () => {
const cache = new LRUCache(100);
for (let i = 0; i < 10_000; i++) {
diff --git a/__tests__/object-registry-synthesizer.test.ts b/__tests__/object-registry-synthesizer.test.ts
index 9b7ac66f7..7060a5ee3 100644
--- a/__tests__/object-registry-synthesizer.test.ts
+++ b/__tests__/object-registry-synthesizer.test.ts
@@ -63,7 +63,8 @@ export function direct() { return new table.add().execute(); }
const db = (cg as any).db.db;
const rows = db
.prepare(
- `SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file
+ `SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file,
+ e.line edge_line, json_extract(e.metadata,'$.registeredAt') registered_at
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
@@ -77,6 +78,8 @@ export function direct() { return new table.add().execute(); }
expect(rows.every((r: any) => r.source_name === 'executeCommand')).toBe(true);
expect(rows.every((r: any) => r.target_kind === 'method' && r.target_name === 'execute')).toBe(true);
expect(rows.every((r: any) => /commands\.ts$/.test(r.target_file))).toBe(true);
+ expect(rows.every((r: any) => r.edge_line === 13)).toBe(true);
+ expect(rows.every((r: any) => /manager\.ts:6$/.test(r.registered_at))).toBe(true);
// The statically-accessed look-alike registry contributed nothing.
expect(rows.some((r: any) => /static\.ts$/.test(r.target_file))).toBe(false);
});
diff --git a/__tests__/query-pool.test.ts b/__tests__/query-pool.test.ts
index d7bf3be91..283e2c927 100644
--- a/__tests__/query-pool.test.ts
+++ b/__tests__/query-pool.test.ts
@@ -116,6 +116,37 @@ describe('QueryPool', () => {
await pool.destroy();
});
+ it('recycles an idle burst back to one fresh worker', async () => {
+ let release!: () => void;
+ const gate = new Promise((r) => { release = r; });
+ const workers: FakeWorker[] = [];
+ const pool = new QueryPool({
+ root: '/x', size: 4, idleShrinkMs: 20,
+ createWorker: () => {
+ const worker = new FakeWorker((m) => ({
+ wait: gate.then(() => ok(`r${m.id}`)),
+ }));
+ workers.push(worker);
+ return worker;
+ },
+ });
+
+ const calls = Promise.all(Array.from({ length: 4 }, (_, i) => pool.run('codegraph_search', { i })));
+ await sleep(40);
+ expect(pool.liveWorkers).toBe(4);
+ release();
+ await calls;
+ await sleep(40);
+
+ expect(pool.liveWorkers).toBe(1);
+ expect(workers).toHaveLength(5); // four used isolates replaced by one clean isolate
+ expect(workers.slice(0, 4).every((w) => !w.alive)).toBe(true);
+ expect(pool.ready).toBe(true);
+ const again = await pool.run('codegraph_node', { symbol: 's' });
+ expect(again.isError).toBeFalsy();
+ await pool.destroy();
+ });
+
it('recovers from a worker crash: retries the in-flight call and respawns', async () => {
let calls = 0;
const pool = new QueryPool({
diff --git a/__tests__/resolution-file-read.test.ts b/__tests__/resolution-file-read.test.ts
new file mode 100644
index 000000000..f9258d20b
--- /dev/null
+++ b/__tests__/resolution-file-read.test.ts
@@ -0,0 +1,42 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { ReferenceResolver } from '../src/resolution';
+import type { QueryBuilder } from '../src/db/queries';
+import type { ResolutionContext } from '../src/resolution/types';
+
+describe('resolution file reads', () => {
+ let root: string;
+ let context: ResolutionContext;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resolution-read-'));
+ const resolver = new ReferenceResolver(root, {} as QueryBuilder);
+ context = (resolver as unknown as { context: ResolutionContext }).context;
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('reads normal source files', () => {
+ fs.writeFileSync(path.join(root, 'small.ts'), 'export const answer = 42;\n');
+ expect(context.readFile('small.ts')).toBe('export const answer = 42;\n');
+ });
+
+ it('rejects an oversized package archive before decoding it as UTF-8', () => {
+ const relative = 'node_modules/example/react_native_openharmony.har';
+ const archive = path.join(root, relative);
+ fs.mkdirSync(path.dirname(archive), { recursive: true });
+ const fd = fs.openSync(archive, 'w');
+ try {
+ fs.writeSync(fd, Buffer.from([0x1f, 0x8b]));
+ fs.ftruncateSync(fd, 2 * 1024 * 1024);
+ } finally {
+ fs.closeSync(fd);
+ }
+
+ expect(context.readFile(relative)).toBeNull();
+ });
+});
diff --git a/__tests__/synthesis-tail-scaling.test.ts b/__tests__/synthesis-tail-scaling.test.ts
index 98729a632..d8276981c 100644
--- a/__tests__/synthesis-tail-scaling.test.ts
+++ b/__tests__/synthesis-tail-scaling.test.ts
@@ -9,7 +9,7 @@
* SQL-side, and language-gates passes off the files table.
*
* These tests pin the query-level building blocks and the end-to-end kotlin
- * bridge so the memory fix can't silently change what gets synthesized.
+ * bridge so the memory fixes can't silently change what gets synthesized.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
@@ -100,4 +100,19 @@ class C {
expect(langs.has('kotlin')).toBe(false);
cg.close();
});
+
+ it('does not rescan source prefixes to locate every synthesized edge', () => {
+ const source = fs.readFileSync(
+ path.resolve('src/resolution/callback-synthesizer.ts'),
+ 'utf8'
+ );
+ const prefixRescans = source
+ .split('\n')
+ .filter((line) => !/^(?:\/\/|\*)/.test(line.trimStart()))
+ .filter((line) => line.includes('.slice(0,') && line.includes(".split('\\n').length"));
+
+ // Repeating this expression for every regex match makes a match-dense file O(n²).
+ // Wall-clock thresholds are too noisy for CI, so pin the allocation pattern directly.
+ expect(prefixRescans).toEqual([]);
+ });
});
diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts
index f493a96cc..5b2d861f1 100644
--- a/__tests__/watcher.test.ts
+++ b/__tests__/watcher.test.ts
@@ -804,6 +804,42 @@ describe('FileWatcher', () => {
expect(calls[0]).toBeUndefined();
});
+ it.skipIf(process.platform !== 'linux')('closes descendant watches when a watched directory is removed', async () => {
+ const calls: (string[] | undefined)[] = [];
+ const callbacks = new Map void>();
+ const closeCounts = new Map();
+ __setFsWatchForTests(((dir: fs.PathLike, _opts: unknown, cb: (event: string, filename: string | Buffer | null) => void) => {
+ const key = String(dir);
+ callbacks.set(key, cb);
+ const watcher = new EventEmitter() as fs.FSWatcher;
+ watcher.close = () => closeCounts.set(key, (closeCounts.get(key) ?? 0) + 1);
+ return watcher;
+ }) as typeof fs.watch);
+ const watcher = new FileWatcher(
+ testDir,
+ async (paths?: string[]) => {
+ calls.push(paths);
+ return { filesChanged: 1, durationMs: 1 };
+ },
+ { debounceMs: 30 }
+ );
+
+ const nestedDir = path.join(testDir, 'src', 'nested');
+ fs.mkdirSync(nestedDir);
+ expect(watcher.start()).toBe(true);
+ const srcDir = path.join(testDir, 'src');
+ expect(callbacks.has(srcDir)).toBe(true);
+ expect(callbacks.has(nestedDir)).toBe(true);
+ fs.rmSync(srcDir, { recursive: true });
+ callbacks.get(testDir)!('rename', 'src');
+ await new Promise((r) => setTimeout(r, 500));
+
+ expect(closeCounts.get(srcDir)).toBe(1);
+ expect(closeCounts.get(nestedDir)).toBe(1);
+ expect(calls[0]).toBeUndefined();
+ watcher.stop();
+ });
+
it('a lone file event fires on the quick window, well before the full debounce', async () => {
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async (paths?: string[]) => {
diff --git a/src/db/queries.ts b/src/db/queries.ts
index 2b8bc5344..1c7ff42ee 100644
--- a/src/db/queries.ts
+++ b/src/db/queries.ts
@@ -1741,8 +1741,8 @@ export class QueryBuilder {
/**
* Get outgoing edges from a node
*/
- getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string): Edge[] {
- if ((kinds && kinds.length > 0) || provenance) {
+ getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string, limit?: number): Edge[] {
+ if ((kinds && kinds.length > 0) || provenance || limit !== undefined) {
let sql = 'SELECT * FROM edges WHERE source = ?';
const params: (string | number)[] = [sourceId];
@@ -1756,6 +1756,11 @@ export class QueryBuilder {
params.push(provenance);
}
+ if (limit !== undefined) {
+ sql += ' LIMIT ?';
+ params.push(Math.max(0, Math.floor(limit)));
+ }
+
const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
return rows.map(rowToEdge);
}
@@ -1770,10 +1775,19 @@ export class QueryBuilder {
/**
* Get incoming edges to a node
*/
- getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] {
- if (kinds && kinds.length > 0) {
- const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`;
- const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[];
+ getIncomingEdges(targetId: string, kinds?: EdgeKind[], limit?: number): Edge[] {
+ if ((kinds && kinds.length > 0) || limit !== undefined) {
+ let sql = 'SELECT * FROM edges WHERE target = ?';
+ const params: (string | number)[] = [targetId];
+ if (kinds && kinds.length > 0) {
+ sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
+ params.push(...kinds);
+ }
+ if (limit !== undefined) {
+ sql += ' LIMIT ?';
+ params.push(Math.max(0, Math.floor(limit)));
+ }
+ const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
return rows.map(rowToEdge);
}
diff --git a/src/extraction/cfml-extractor.ts b/src/extraction/cfml-extractor.ts
index 2f4bc4779..c873443bc 100644
--- a/src/extraction/cfml-extractor.ts
+++ b/src/extraction/cfml-extractor.ts
@@ -112,8 +112,14 @@ export class CfmlExtractor {
return;
}
- const fileNode = this.createFileNode();
- this.walkProgram(tree.rootNode, fileNode.id);
+ try {
+ const fileNode = this.createFileNode();
+ this.walkProgram(tree.rootNode, fileNode.id);
+ } finally {
+ // Tree-sitter trees own WASM/native memory outside V8's heap. Tag-based
+ // CFML bypasses TreeSitterExtractor, so it must release its tree here.
+ tree.delete();
+ }
}
/** Build the file's own `kind:'file'` node, spanning the whole source. Tag-based files need this explicitly — unlike `extractBareScript` (which delegates the whole file to `TreeSitterExtractor` and inherits its file node), `extractTagBased` walks the tree itself and has no other source of one. */
diff --git a/src/extraction/index.ts b/src/extraction/index.ts
index 2b61636b6..86da64e29 100644
--- a/src/extraction/index.ts
+++ b/src/extraction/index.ts
@@ -35,6 +35,7 @@ import ignore, { Ignore } from 'ignore';
import { detectFrameworks } from '../resolution/frameworks';
import type { ResolutionContext } from '../resolution/types';
import { createYielder, type MaybeYield } from '../resolution/cooperative-yield';
+import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits';
/**
* Number of files to read in parallel during indexing.
@@ -139,13 +140,6 @@ export function hashContent(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex');
}
-/**
- * Skip files larger than this (bytes). Generated bundles, minified JS, and
- * vendored blobs blow the WASM heap and the worker-recycle budget for no useful
- * symbols. 1 MB covers essentially all hand-written source.
- */
-const MAX_FILE_SIZE = 1024 * 1024;
-
/**
* Directory names that are dependency, build, cache, or tooling output across the
* languages/frameworks CodeGraph supports — curated from the canonical
@@ -1910,18 +1904,18 @@ export class ExtractionOrchestrator {
continue;
}
- // Honour MAX_FILE_SIZE. Without this check, vendored generated
+ // Honour MAX_SOURCE_FILE_SIZE_BYTES. Without this check, vendored generated
// headers, minified bundles, and other multi-MB files get indexed,
// wasting WASM heap and the worker recycle budget on inputs with no
// useful symbols. The single-file extractFile path already enforces
// this; the bulk path used to silently skip the check.
- if (stats.size > MAX_FILE_SIZE) {
+ if (stats.size > MAX_SOURCE_FILE_SIZE_BYTES) {
await storeResult(filePath, content, stats, {
nodes: [],
edges: [],
unresolvedReferences: [],
errors: [{
- message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`,
+ message: `File exceeds max size (${stats.size} > ${MAX_SOURCE_FILE_SIZE_BYTES})`,
filePath,
severity: 'warning',
code: 'size_exceeded',
@@ -2249,14 +2243,14 @@ export class ExtractionOrchestrator {
const language = detectLanguage(relativePath, content, loadExtensionOverrides(this.rootDir));
// Check file size
- if (stats.size > MAX_FILE_SIZE) {
+ if (stats.size > MAX_SOURCE_FILE_SIZE_BYTES) {
const result: ExtractionResult = {
nodes: [],
edges: [],
unresolvedReferences: [],
errors: [
{
- message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`,
+ message: `File exceeds max size (${stats.size} > ${MAX_SOURCE_FILE_SIZE_BYTES})`,
filePath: relativePath,
severity: 'warning',
code: 'size_exceeded',
diff --git a/src/file-limits.ts b/src/file-limits.ts
new file mode 100644
index 000000000..4bb4d6da1
--- /dev/null
+++ b/src/file-limits.ts
@@ -0,0 +1,6 @@
+/**
+ * Largest source file CodeGraph will parse or read during resolution. Generated
+ * bundles, minified sources, and dependency archives above this limit provide no
+ * useful symbols; 1 MB covers essentially all hand-written source.
+ */
+export const MAX_SOURCE_FILE_SIZE_BYTES = 1024 * 1024;
diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts
index 5e9b354e9..a6eb66364 100644
--- a/src/graph/traversal.ts
+++ b/src/graph/traversal.ts
@@ -4,7 +4,7 @@
* BFS and DFS traversal for the code knowledge graph.
*/
-import { Node, Edge, Subgraph, TraversalOptions, EdgeKind } from '../types';
+import { Node, Edge, Subgraph, TraversalOptions, EdgeKind, ImpactOptions, EDGE_KINDS } from '../types';
import { QueryBuilder } from '../db/queries';
/**
@@ -19,6 +19,10 @@ const DEFAULT_OPTIONS: Required = {
includeStart: true,
};
+const DEFAULT_IMPACT_MAX_NODES = 10_000;
+const DEFAULT_IMPACT_MAX_EDGES = 50_000;
+const IMPACT_INCOMING_KINDS = EDGE_KINDS.filter((kind) => kind !== 'contains');
+
/**
* Result of a single traversal step
*/
@@ -517,7 +521,7 @@ export class GraphTraverser {
* @param maxDepth - Maximum depth to traverse (default: 3)
* @returns Subgraph containing potentially impacted nodes
*/
- getImpactRadius(nodeId: string, maxDepth: number = 3): Subgraph {
+ getImpactRadius(nodeId: string, maxDepth: number = 3, options: ImpactOptions = {}): Subgraph {
const focalNode = this.queries.getNodeById(nodeId);
if (!focalNode) {
return { nodes: new Map(), edges: [], roots: [] };
@@ -526,17 +530,23 @@ export class GraphTraverser {
const nodes = new Map();
const edges: Edge[] = [];
const visited = new Set();
+ const budget = {
+ maxNodes: Math.max(1, Math.floor(options.maxNodes ?? DEFAULT_IMPACT_MAX_NODES)),
+ maxEdges: Math.max(0, Math.floor(options.maxEdges ?? DEFAULT_IMPACT_MAX_EDGES)),
+ truncated: false,
+ };
// Add focal node
nodes.set(focalNode.id, focalNode);
// Traverse incoming edges to find all dependents
- this.getImpactRecursive(nodeId, maxDepth, 0, nodes, edges, visited);
+ this.getImpactRecursive(nodeId, maxDepth, 0, nodes, edges, visited, budget);
return {
nodes,
edges,
roots: [nodeId],
+ truncated: budget.truncated || undefined,
};
}
@@ -546,7 +556,8 @@ export class GraphTraverser {
currentDepth: number,
nodes: Map,
edges: Edge[],
- visited: Set
+ visited: Set,
+ budget: { maxNodes: number; maxEdges: number; truncated: boolean }
): void {
// Mark visited before the depth check so a node collected at the depth
// boundary still lands in `visited`. Otherwise it could sit in `nodes` but
@@ -566,16 +577,23 @@ export class GraphTraverser {
if (focalNode) {
const containerKinds = new Set(['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'module', 'enum']);
if (containerKinds.has(focalNode.kind)) {
- const containsEdges = this.queries.getOutgoingEdges(nodeId, ['contains']);
+ const remaining = budget.maxEdges - edges.length;
+ const fetched = this.queries.getOutgoingEdges(nodeId, ['contains'], undefined, remaining + 1);
+ if (fetched.length > remaining) budget.truncated = true;
+ const containsEdges = fetched.slice(0, remaining);
if (containsEdges.length > 0) {
const children = this.queries.getNodesByIds(containsEdges.map((e) => e.target));
for (const edge of containsEdges) {
const childNode = children.get(edge.target);
if (childNode && !visited.has(childNode.id)) {
+ if (nodes.size >= budget.maxNodes || edges.length >= budget.maxEdges) {
+ budget.truncated = true;
+ continue;
+ }
nodes.set(childNode.id, childNode);
edges.push(edge);
// Recurse into children at the same depth (they're part of the same symbol)
- this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited);
+ this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited, budget);
}
}
}
@@ -586,7 +604,12 @@ export class GraphTraverser {
// `contains`: a container "contains" its members but does not *depend* on
// them, so following it upward would climb to the parent class and then
// re-expand every sibling member — exploding impact for a leaf symbol. (#536)
- const incomingEdges = this.queries.getIncomingEdges(nodeId).filter((e) => e.kind !== 'contains');
+ const remaining = budget.maxEdges - edges.length;
+ // Fetch one extra row so hitting the SQL LIMIT is distinguishable from an
+ // exact-size result and can be surfaced as explicit truncation.
+ const fetched = this.queries.getIncomingEdges(nodeId, IMPACT_INCOMING_KINDS, remaining + 1);
+ if (fetched.length > remaining) budget.truncated = true;
+ const incomingEdges = fetched.slice(0, remaining);
if (incomingEdges.length === 0) return;
const sources = this.queries.getNodesByIds(incomingEdges.map((e) => e.source));
@@ -598,10 +621,18 @@ export class GraphTraverser {
// node already collected via another path was silently dropped from
// `edges` even though it's a real dependency (#1089). Each node's incoming
// edges are fetched once (nodes are expanded once), so no edge repeats.
+ if (!nodes.has(sourceNode.id) && nodes.size >= budget.maxNodes) {
+ budget.truncated = true;
+ continue;
+ }
+ if (edges.length >= budget.maxEdges) {
+ budget.truncated = true;
+ continue;
+ }
edges.push(edge);
if (!visited.has(sourceNode.id)) {
nodes.set(sourceNode.id, sourceNode);
- this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited);
+ this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited, budget);
}
}
}
@@ -626,24 +657,31 @@ export class GraphTraverser {
return null;
}
- // BFS to find shortest path
- const visited = new Set();
- const queue: Array<{ nodeId: string; path: Array<{ node: Node; edge: Edge | null }> }> = [
- { nodeId: fromId, path: [{ node: fromNode, edge: null }] },
- ];
+ // BFS to find the shortest path. Keep one predecessor per discovered node
+ // instead of copying the full path into every queue entry; use a head index
+ // so dequeues stay O(1), and mark on enqueue so converging edges cannot
+ // multiply queued work.
+ const enqueued = new Set([fromId]);
+ const parents = new Map();
+ const queue: string[] = [fromId];
+ let head = 0;
- while (queue.length > 0) {
- const { nodeId, path } = queue.shift()!;
+ while (head < queue.length) {
+ const nodeId = queue[head++]!;
if (nodeId === toId) {
+ const path: Array<{ node: Node; edge: Edge | null }> = [];
+ let currentId = toId;
+ while (currentId !== fromId) {
+ const step = parents.get(currentId)!;
+ path.push({ node: step.node, edge: step.edge });
+ currentId = step.parentId;
+ }
+ path.push({ node: fromNode, edge: null });
+ path.reverse();
return path;
}
- if (visited.has(nodeId)) {
- continue;
- }
- visited.add(nodeId);
-
// Get outgoing edges
const outgoingEdges = this.queries.getOutgoingEdges(
nodeId,
@@ -651,22 +689,20 @@ export class GraphTraverser {
);
if (outgoingEdges.length === 0) continue;
- // Batch-fetch only the unvisited targets (was N+1 per BFS frontier).
- const wantIds = outgoingEdges
- .map((e) => e.target)
- .filter((id) => !visited.has(id));
+ // Batch-fetch only undiscovered targets, once each even when parallel
+ // edges point at the same node.
+ const wantIds = [...new Set(
+ outgoingEdges.map((e) => e.target).filter((id) => !enqueued.has(id))
+ )];
const nextNodes = wantIds.length > 0 ? this.queries.getNodesByIds(wantIds) : new Map();
for (const edge of outgoingEdges) {
- if (!visited.has(edge.target)) {
- const nextNode = nextNodes.get(edge.target);
- if (nextNode) {
- queue.push({
- nodeId: edge.target,
- path: [...path, { node: nextNode, edge }],
- });
- }
- }
+ if (enqueued.has(edge.target)) continue;
+ const nextNode = nextNodes.get(edge.target);
+ if (!nextNode) continue;
+ enqueued.add(edge.target);
+ parents.set(edge.target, { parentId: nodeId, node: nextNode, edge });
+ queue.push(edge.target);
}
}
diff --git a/src/index.ts b/src/index.ts
index 2942575b5..6a1e18fbf 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -23,6 +23,7 @@ import {
TaskContext,
BuildContextOptions,
FindRelevantContextOptions,
+ ImpactOptions,
} from './types';
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve';
@@ -1705,8 +1706,8 @@ export class CodeGraph {
* @param maxDepth - Maximum depth to traverse (default: 3)
* @returns Subgraph containing potentially impacted nodes
*/
- getImpactRadius(nodeId: string, maxDepth: number = 3): Subgraph {
- return this.traverser.getImpactRadius(nodeId, maxDepth);
+ getImpactRadius(nodeId: string, maxDepth: number = 3, options: ImpactOptions = {}): Subgraph {
+ return this.traverser.getImpactRadius(nodeId, maxDepth, options);
}
/**
diff --git a/src/mcp/query-pool.ts b/src/mcp/query-pool.ts
index 0575def38..b9d36f8e4 100644
--- a/src/mcp/query-pool.ts
+++ b/src/mcp/query-pool.ts
@@ -48,6 +48,10 @@ export interface PoolWorker {
/** Default linger before a queued call is answered with busy-guidance. */
const DEFAULT_BUSY_TIMEOUT_MS = 45_000; // < the ~60s MCP client request timeout
+/** Recycle a quiescent pool after a burst so worker-local graph/SQLite caches
+ * do not remain resident for the daemon's whole lifetime. */
+const DEFAULT_IDLE_SHRINK_MS = 60_000;
+
/** Hard ceiling on pool size regardless of core count / env. */
const MAX_POOL_SIZE = 16;
@@ -97,6 +101,8 @@ export interface QueryPoolOptions {
softTimeoutMs?: number;
/** Retries for an in-flight call whose worker crashed. Default 1. */
maxRetries?: number;
+ /** Idle delay before recycling back to one fresh worker. Default 60s; 0 disables. */
+ idleShrinkMs?: number;
/** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */
createWorker?: () => PoolWorker;
}
@@ -157,13 +163,16 @@ export class QueryPool {
private readonly maxSize: number;
private readonly softTimeoutMs: number;
private readonly maxRetries: number;
+ private readonly idleShrinkMs: number;
private readonly createWorker: () => PoolWorker;
+ private idleShrinkTimer?: NodeJS.Timeout;
constructor(opts: QueryPoolOptions) {
this.root = opts.root;
this.maxSize = Math.max(1, Math.min(opts.size ?? Math.max(1, os.cpus().length - 1), MAX_POOL_SIZE));
this.softTimeoutMs = opts.softTimeoutMs ?? resolveBusyTimeoutMs();
this.maxRetries = opts.maxRetries ?? 1;
+ this.idleShrinkMs = Math.max(0, opts.idleShrinkMs ?? DEFAULT_IDLE_SHRINK_MS);
this.createWorker = opts.createWorker ?? (() => new Worker(WORKER_FILE, { workerData: { root: this.root } }));
this.spawnOne(); // one eager warm worker, ready for the first call
}
@@ -232,7 +241,45 @@ export class QueryPool {
this.idle.push(w);
if (job) this.settle(job, m.result ?? busyGuidance(0));
this.drain();
+ this.armIdleShrink();
+ }
+ }
+
+ private clearIdleShrink(): void {
+ if (this.idleShrinkTimer) clearTimeout(this.idleShrinkTimer);
+ this.idleShrinkTimer = undefined;
+ }
+
+ private armIdleShrink(): void {
+ this.clearIdleShrink();
+ if (
+ this.idleShrinkMs === 0 || this.destroyed || this.queue.length > 0 ||
+ this.inflight.size > 0 || this.pendingWorkers.size > 0 ||
+ this.idle.length !== this.workers.size
+ ) return;
+ this.idleShrinkTimer = setTimeout(() => this.shrinkIdle(), this.idleShrinkMs);
+ this.idleShrinkTimer.unref?.();
+ }
+
+ private shrinkIdle(): void {
+ this.idleShrinkTimer = undefined;
+ if (
+ this.destroyed || this.queue.length > 0 || this.inflight.size > 0 ||
+ this.pendingWorkers.size > 0 || this.idle.length !== this.workers.size
+ ) return;
+
+ // Drop every used isolate, including the last one: a single worker can hold
+ // hundreds of MB in graph and SQLite caches after a large query. Replace it
+ // with one clean warm worker so the next burst still avoids a cold queue.
+ const stale = [...this.workers];
+ this.workers.clear();
+ this.idle = [];
+ this.everReady = false;
+ for (const w of stale) {
+ try { void Promise.resolve(w.terminate()).catch(() => { /* already gone */ }); }
+ catch { /* already gone */ }
}
+ this.spawnOne();
}
// A worker died (crash hook, OOM, segfault, exit≠0). Respawn a replacement and
@@ -291,6 +338,7 @@ export class QueryPool {
/** Run a read tool on the pool. Always resolves (never rejects). */
run(toolName: string, args: Record): Promise {
+ this.clearIdleShrink();
return new Promise((resolve) => {
const job: Job = {
id: this.nextId++, toolName, args, resolve,
@@ -312,6 +360,7 @@ export class QueryPool {
async destroy(): Promise {
if (this.destroyed) return;
this.destroyed = true;
+ this.clearIdleShrink();
const ws = [...this.workers];
this.workers.clear();
this.pendingWorkers.clear();
diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts
index 4ad7e64b6..482766ec1 100644
--- a/src/mcp/tools.ts
+++ b/src/mcp/tools.ts
@@ -2364,23 +2364,39 @@ export class ToolHandler {
: '';
const impactOf = (defNodes: Node[]) => {
+ const maxNodes = 1_000;
+ const maxEdges = 5_000;
const mergedNodes = new Map();
const mergedEdges: Edge[] = [];
const seenEdges = new Set();
+ let truncated = false;
for (const node of defNodes) {
- const impact = cg.getImpactRadius(node.id, depth);
+ const impact = cg.getImpactRadius(node.id, depth, { maxNodes, maxEdges });
+ truncated ||= impact.truncated === true;
for (const [id, n] of impact.nodes) {
+ if (!mergedNodes.has(id) && mergedNodes.size >= maxNodes) {
+ truncated = true;
+ continue;
+ }
mergedNodes.set(id, n);
}
for (const e of impact.edges) {
+ if (!mergedNodes.has(e.source) || !mergedNodes.has(e.target)) {
+ truncated = true;
+ continue;
+ }
const key = `${e.source}->${e.target}:${e.kind}`;
if (!seenEdges.has(key)) {
+ if (mergedEdges.length >= maxEdges) {
+ truncated = true;
+ continue;
+ }
seenEdges.add(key);
mergedEdges.push(e);
}
}
}
- return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id) };
+ return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id), truncated };
};
// Single definition (or same-file overloads): the familiar merged report.
@@ -6918,9 +6934,12 @@ export class ToolHandler {
// Compact format: just list affected symbols grouped by file
const lines: string[] = [
- `**Impact: "${symbol}" affects ${nodeCount} symbols**`,
+ `**Impact: "${symbol}" affects ${nodeCount} symbols${impact.truncated ? ' (truncated at safety limit)' : ''}**`,
'',
];
+ if (impact.truncated) {
+ lines.push('> Result truncated to protect the MCP process on a high-fanout graph. Narrow with `file` or reduce `depth`.', '');
+ }
// Group by file
const byFile = new Map();
diff --git a/src/resolution/c-fnptr-synthesizer.ts b/src/resolution/c-fnptr-synthesizer.ts
index 568b782c9..9d9a760d1 100644
--- a/src/resolution/c-fnptr-synthesizer.ts
+++ b/src/resolution/c-fnptr-synthesizer.ts
@@ -178,6 +178,12 @@ interface MacroDef {
expansion: string;
}
+/** Regex captures are sliced strings in V8. A cached tiny macro capture would
+ * otherwise pin its whole multi-megabyte source header; force an owned copy. */
+function ownString(value: string): string {
+ return Buffer.from(value, 'utf8').toString('utf8');
+}
+
/**
* Collect function-like macros from (comment-stripped) source, joining
* `\`-continuations first. Only object/positional table macros matter here, so
@@ -191,9 +197,9 @@ function parseFunctionMacros(stripped: string): Map {
const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)\(([^)]*)\)\s+(.+)$/gm;
let m: RegExpExecArray | null;
while ((m = RE.exec(joined))) {
- const params = m[2]!.split(',').map((p) => p.trim()).filter(Boolean);
+ const params = m[2]!.split(',').map((p) => p.trim()).filter(Boolean).map(ownString);
if (params.some((p) => p === '...' || p.endsWith('...'))) continue; // variadic — skip
- out.set(m[1]!, { params, expansion: m[3]!.trim() });
+ out.set(ownString(m[1]!), { params, expansion: ownString(m[3]!.trim()) });
}
return out;
}
@@ -207,22 +213,41 @@ function parseObjectMacros(stripped: string): Map {
const out = new Map();
if (!stripped.includes('#define') && !stripped.includes('# define')) return out;
const joined = stripped.replace(/\\\r?\n/g, ' ');
- const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(\S[^\n]*)$/gm;
+ const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+((?:(?:struct|union)[ \t]+)?[A-Za-z_]\w*)[ \t\r]*$/gm;
let m: RegExpExecArray | null;
- while ((m = RE.exec(joined))) out.set(m[1]!, m[2]!.trim());
+ while ((m = RE.exec(joined))) out.set(ownString(m[1]!), ownString(m[2]!));
return out;
}
/** All macro names a file `#define`s (value-ful or not) — the "defined" set for #ifdef. */
-function parseDefinedNames(stripped: string): Set {
+function parseDefinedNames(stripped: string, relevant: Set): Set {
const out = new Set();
- if (!stripped.includes('#define') && !stripped.includes('# define')) return out;
+ if (relevant.size === 0 || (!stripped.includes('#define') && !stripped.includes('# define'))) return out;
const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)/gm;
let m: RegExpExecArray | null;
- while ((m = RE.exec(stripped))) out.add(m[1]!);
+ while ((m = RE.exec(stripped))) {
+ if (relevant.has(m[1]!)) out.add(ownString(m[1]!));
+ }
return out;
}
+/** Names the conditional evaluator can observe. Keeping only these in each
+ * file's defined-set drops millions of numeric register macros that can never
+ * affect an `#ifdef`/`defined(NAME)` branch in any registration unit. */
+function collectConditionalNames(source: string, out: Set): void {
+ if (!source.includes('#if') && !source.includes('# if') && !source.includes('#elif')) return;
+ const ifdef = /^[ \t]*#[ \t]*(?:ifdef|ifndef)[ \t]+(\w+)/gm;
+ let m: RegExpExecArray | null;
+ while ((m = ifdef.exec(source))) out.add(ownString(m[1]!));
+ const line = /^[ \t]*#[ \t]*(?:if|elif)\b([^\n]*)$/gm;
+ while ((m = line.exec(source))) {
+ const expr = m[1]!;
+ const defined = /\bdefined\s*(?:\(\s*)?(\w+)/g;
+ let d: RegExpExecArray | null;
+ while ((d = defined.exec(expr))) out.add(ownString(d[1]!));
+ }
+}
+
/**
* Drop the inactive arms of `#ifdef`/`#ifndef`/`#if defined(X)`/`#else`/`#elif`/
* `#endif` given a set of defined macro names, keeping line offsets (inactive
@@ -370,7 +395,7 @@ const INCLUDABLE_EXT = /\.(def|inc|h|hh|hpp|hxx|c|cc|cpp|cxx|ipp|tcc|tbl)$/i;
* are excluded: `resolveTypeName` would rewrite to a dead-end token that can
* never name a struct, so skipping them is exact, and it drops the register
* flood. */
-const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(?:(?:struct|union)[ \t]+)*[A-Za-z_]\w*[ \t\r]*$/gm;
+const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+((?:(?:struct|union)[ \t]+)?[A-Za-z_]\w*)[ \t\r]*$/gm;
/** `(?:struct )?TYPE name[opt] = {` initializers, where TYPE is a struct that
* has ≥1 fn-pointer field. Handles both single (`= {…}`) and array
@@ -469,19 +494,26 @@ export async function cFnPointerDispatchEdges(
// The extraction sweep reads sequentially; the linking stages re-request
// only surviving files (plus include units), so access is near-sequential
// and a small LRU hits; a miss just re-reads + re-strips.
- // Cache sizing is memory-budget-aware AND all-or-nothing (§7a.3 cFnPtr
- // round): a partial LRU is WORSE than useless for cyclic sweeps (a first
- // attempt sized ~61k against 63.8k files thrashed to a ~0% cross-sweep hit
- // rate). Hold every stripped file (~24KB each measured on the Linux tree)
- // only when 40% of the live memory budget covers it; otherwise keep the
- // within-stage-locality 128. When the big cache declines (the kernel), the
- // survival filters keep the linking stages' re-strips to a fraction of a
- // sweep. Slack over files.length: non-indexed includes (.def/.inc, generated
- // headers) join the working set mid-pass. Pass-scoped transient, freed on
- // return.
+ // Bound by ACTUAL retained string bytes, not an average bytes-per-file guess:
+ // large generated files made the old entry-count estimate understate the
+ // cache by multiples and drove the main-thread resolution pass into OOM.
+ // UTF-16 length*2 is conservative for V8's one-byte strings and correctly
+ // bounds the worst case. The shared 128MB ceiling is also capped at 5% of
+ // currently-available memory, leaving room for the DB, fact tables and edges.
+ // The survival filters keep cache misses in later stages to a fraction of a
+ // full sweep, so bounded re-reads are preferable to an unbounded peak.
const fullCacheCap = Math.ceil(files.length * 1.05) + 512;
- const cacheCap = memoryBudgetBytes() * 0.5 >= fullCacheCap * 24_576 ? fullCacheCap : 128;
- const rawCache = new LRUCache(Math.min(cacheCap, 4096));
+ const totalTextBudget = Math.max(
+ 16 * 1024 * 1024,
+ Math.min(128 * 1024 * 1024, Math.floor(memoryBudgetBytes() * 0.05))
+ );
+ const rawBudget = Math.floor(totalTextBudget / 3);
+ const srcBudget = totalTextBudget - rawBudget;
+ const stringWeight = (value: string | null): number => value === null ? 1 : Math.max(64, value.length * 2);
+ const rawCache = new LRUCache(Math.min(fullCacheCap, 4096), {
+ maxWeight: rawBudget,
+ weightOf: stringWeight,
+ });
const raw = (file: string): string | null => {
if (rawCache.has(file)) return rawCache.get(file)!;
const t0 = prof ? Date.now() : 0;
@@ -490,7 +522,10 @@ export async function cFnPointerDispatchEdges(
rawCache.set(file, r);
return r;
};
- const srcCache = new LRUCache(cacheCap);
+ const srcCache = new LRUCache(fullCacheCap, {
+ maxWeight: srcBudget,
+ weightOf: stringWeight,
+ });
const src = (file: string): string | null => {
// A cached '' (empty or unreadable file) returns '' where the miss path
// returns null for unreadable — every caller falsy-checks, so the two are
@@ -548,6 +583,8 @@ export async function cFnPointerDispatchEdges(
const inlineTags = new Set();
/** Object-macro names with an alias-shaped value anywhere (see OBJ_ALIAS_RE). */
const aliasNames = new Set();
+ /** Macro names that can affect a supported preprocessor conditional anywhere. */
+ const conditionalNames = new Set();
// Parse a struct body (the text between its `{` and `}`) into ordered fields,
// structure only — see RawFieldDecl for why classification is deferred.
@@ -698,6 +735,7 @@ export async function cFnPointerDispatchEdges(
await tick();
const rawText = raw(file);
if (!rawText) continue; // unreadable or empty — the JS sweep skips these too
+ collectConditionalNames(rawText, conditionalNames);
const tN = prof ? Date.now() : 0;
const fileNodes = ctx.getNodesInFile(file);
if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; }
@@ -719,6 +757,7 @@ export async function cFnPointerDispatchEdges(
await tick();
const s = src(file);
if (!s) continue;
+ collectConditionalNames(s, conditionalNames);
// Typedefs (cross-file).
if (s.includes('typedef')) {
@@ -991,22 +1030,50 @@ export async function cFnPointerDispatchEdges(
// parsed tables is ruled out by the kernel's 6.1M `#define`s, and the
// registration stage below only builds an env for files that survive its
// filter or carry local includes, so most files never need one.
- const fnMacroCache = new LRUCache>(256);
+ const macroCacheBudget = Math.min(64 * 1024 * 1024, Math.max(8 * 1024 * 1024, totalTextBudget / 2));
+ const fnMacroWeight = (macros: Map): number => {
+ let chars = 0;
+ for (const [name, def] of macros) {
+ chars += name.length + def.expansion.length;
+ for (const param of def.params) chars += param.length;
+ }
+ return Math.max(64, chars * 2);
+ };
+ const objMacroWeight = (macros: Map): number => {
+ let chars = 0;
+ for (const [name, value] of macros) chars += name.length + value.length;
+ return Math.max(64, chars * 2);
+ };
+ const definedWeight = (names: Set): number => {
+ let chars = 0;
+ for (const name of names) chars += name.length;
+ return Math.max(64, chars * 2);
+ };
+ const fnMacroCache = new LRUCache>(256, {
+ maxWeight: Math.floor(macroCacheBudget * 0.4),
+ weightOf: fnMacroWeight,
+ });
const fileFnMacros = (file: string): Map => {
let m = fnMacroCache.get(file);
if (!m) { m = parseFunctionMacros(src(file) ?? ''); fnMacroCache.set(file, m); }
return m;
};
- const objMacroCache = new LRUCache>(256);
+ const objMacroCache = new LRUCache>(256, {
+ maxWeight: Math.floor(macroCacheBudget * 0.2),
+ weightOf: objMacroWeight,
+ });
const fileObjMacros = (file: string): Map => {
let m = objMacroCache.get(file);
if (!m) { m = parseObjectMacros(src(file) ?? ''); objMacroCache.set(file, m); }
return m;
};
- const definedCache = new LRUCache>(256);
+ const definedCache = new LRUCache>(256, {
+ maxWeight: Math.floor(macroCacheBudget * 0.4),
+ weightOf: definedWeight,
+ });
const fileDefinedNames = (file: string): Set => {
let d = definedCache.get(file);
- if (!d) { d = parseDefinedNames(src(file) ?? ''); definedCache.set(file, d); }
+ if (!d) { d = parseDefinedNames(src(file) ?? '', conditionalNames); definedCache.set(file, d); }
return d;
};
@@ -1204,6 +1271,25 @@ export async function cFnPointerDispatchEdges(
processUnit({ text, file: target, env: incEnv, objEnv: incObjEnv });
}
}
+ // Registration-only facts and memo tables are substantial on C-heavy repos;
+ // release them before propagation/dispatch allocate their own working sets.
+ for (const facts of factsByFile.values()) {
+ facts.initTokens = null;
+ facts.arrayElems = null;
+ facts.inlineTypes = null;
+ facts.includes = NO_INCLUDES;
+ }
+ inlineTags.clear();
+ aliasNames.clear();
+ includeCache.clear();
+ fnMacroCache.clear();
+ objMacroCache.clear();
+ definedCache.clear();
+ seenInclude.clear();
+ interned.clear();
+ fnPtrTypedefs.clear();
+ fnTypeTypedefs.clear();
+ conditionalNames.clear();
if (prof) { prof.C = Date.now() - tPass; tPass = Date.now(); }
// ---- receiver-type resolution within a function's source ----
@@ -1321,6 +1407,8 @@ export async function cFnPointerDispatchEdges(
}
if (!changed) break;
}
+ propagations.length = 0;
+ for (const facts of factsByFile.values()) facts.dPairs = null;
if (prof) { prof.D = Date.now() - tPass; tPass = Date.now(); }
if (reg.size === 0 && arrayReg.size === 0) return [];
diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts
index 60b389937..195e364d7 100644
--- a/src/resolution/callback-synthesizer.ts
+++ b/src/resolution/callback-synthesizer.ts
@@ -609,6 +609,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P
const content = ctx.readFile(file);
if (!content || !content.includes('emitter.')) continue;
const safe = stripCommentsForRegex(content, 'typescript');
+ const lineAt = makeLineAt(safe, 1);
const nodes = ctx.getNodesInFile(file)
.filter((n) => n.kind === 'method' || n.kind === 'function');
@@ -617,7 +618,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P
while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) {
const verb = m[1]!;
const arg = m[2]!.trim();
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
const encl = nodes
.filter((n) => n.startLine <= line && n.endLine >= line)
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
@@ -714,6 +715,7 @@ async function arkuiRouterEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
const content = ctx.readFile(file);
if (!content || !content.includes('router.')) continue;
const safe = stripCommentsForRegex(content, 'typescript');
+ const lineAt = makeLineAt(safe, 1);
const nodes = ctx.getNodesInFile(file)
.filter((n) => n.kind === 'method' || n.kind === 'function');
@@ -721,7 +723,7 @@ async function arkuiRouterEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
let m: RegExpExecArray | null;
while ((m = ARKUI_ROUTER_RE.exec(safe))) {
const url = m[1]!;
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
const encl = nodes
.filter((n) => n.startLine <= line && n.endLine >= line)
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
@@ -1961,13 +1963,14 @@ async function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionCon
const content = ctx.readFile(file);
if (!content || (!content.includes('.Use(') && !/\.(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\(/.test(content))) continue;
const safe = stripCommentsForRegex(content, 'go');
+ const lineAt = makeLineAt(safe, 1);
GIN_REG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = GIN_REG_RE.exec(safe))) {
const parenIdx = m.index + m[0].length - 1;
const argStr = goBalancedArgs(safe, parenIdx);
if (!argStr) continue;
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
for (const arg of goSplitArgs(argStr)) {
const name = goHandlerIdent(arg);
if (name && !registered.has(name)) registered.set(name, `${file}:${line}`);
@@ -2115,6 +2118,7 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on
if (!src) continue;
// Thunks are TS/JS-family (same // and /* */ comment syntax); map to a CommentLang.
const safe = stripCommentsForRegex(src, node.language === 'javascript' || node.language === 'jsx' ? 'javascript' : 'typescript');
+ const lineAt = makeLineAt(safe, node.startLine);
THUNK_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
let added = 0;
@@ -2139,7 +2143,7 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on
const key = `${node.id}>${target.id}`;
if (seen.has(key)) continue;
seen.add(key);
- const line = node.startLine + safe.slice(0, m.index).split('\n').length - 1;
+ const line = lineAt(m.index);
edges.push({
source: node.id,
target: target.id,
@@ -2248,6 +2252,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield):
const newlines = (content.match(/\n/g)?.length ?? 0) + 1;
if (content.length / newlines > 200) continue;
const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
+ const lineAt = makeLineAt(safe, 1);
// 1. Dispatch sites: `(new )?[[]` followed by a call or a chained method.
// A quoted-string key (`['save']`) does NOT match — that's a static access, not dispatch.
@@ -2257,7 +2262,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield):
while ((dm = REGISTRY_DISPATCH_RE.exec(safe))) {
const win = safe.slice(dm.index, dm.index + 160);
const cm = /\]\s*\([^)]*\)\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win) || /\]\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win);
- dispatches.push({ ref: dm[1]!, line: safe.slice(0, dm.index).split('\n').length, chained: cm ? cm[1]! : null });
+ dispatches.push({ ref: dm[1]!, line: lineAt(dm.index), chained: cm ? cm[1]! : null });
}
if (!dispatches.length) continue;
// Normalize a leading `this.` so a class FIELD-INITIALIZER registry (`commands = {…}`)
@@ -2276,7 +2281,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield):
if (!body) continue;
const names = registryEntryNames(body); // depth-0 `key: Identifier` entries only
if (names.length >= REGISTRY_MIN_ENTRIES) {
- registries.set(lhs, { names, line: safe.slice(0, am.index).split('\n').length });
+ registries.set(lhs, { names, line: lineAt(am.index) });
}
}
if (!registries.size) continue;
@@ -2408,6 +2413,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro
const content = ctx.readFile(file);
if (!content || !content.includes('Store')) continue;
const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
+ const lineAt = makeLineAt(safe, 1);
// 2. Bind store vars in this file: `const = (...)`.
const varStore = new Map();
@@ -2429,7 +2435,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro
const storeFile = varStore.get(cm[1]!);
if (!storeFile) continue;
const method = cm[2]!;
- const line = safe.slice(0, cm.index).split('\n').length;
+ const line = lineAt(cm.index);
const disp = enclosingFn(nodesInFile, line) ?? fallbackDispatcher;
if (!disp) continue;
const target = ctx
@@ -2514,6 +2520,7 @@ async function vuexDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): P
const content = ctx.readFile(file);
if (!content || (!content.includes('dispatch(') && !content.includes('commit('))) continue;
const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
+ const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
const fallback = nodesInFile.find((n) => n.kind === 'component'); // .vue top-level
VUEX_DISPATCH_RE.lastIndex = 0;
@@ -2521,7 +2528,7 @@ async function vuexDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): P
let added = 0;
while ((m = VUEX_DISPATCH_RE.exec(safe)) && added < VUEX_FANOUT_CAP) {
const key = m[1]!;
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line) ?? fallback;
if (!disp) continue;
const target = resolve(key, file);
@@ -2611,13 +2618,14 @@ async function celeryDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield):
const content = ctx.readFile(file);
if (!content || (!content.includes('.delay(') && !content.includes('.apply_async('))) continue;
const safe = stripCommentsForRegex(content, 'python');
+ const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
CELERY_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
let added = 0;
while ((m = CELERY_DISPATCH_RE.exec(safe)) && added < CELERY_FANOUT_CAP) {
const name = m[1]!;
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue; // module-level dispatch — no source symbol to attribute
const target = resolve(name, file);
@@ -2732,6 +2740,7 @@ async function springEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
const content = ctx.readFile(file);
if (!content || !content.includes('.publishEvent(')) continue;
const safe = stripCommentsForRegex(content, 'java');
+ const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
SPRING_PUBLISH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
@@ -2739,7 +2748,7 @@ async function springEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
while ((m = SPRING_PUBLISH_RE.exec(safe)) && added < SPRING_FANOUT_CAP) {
const targets = listeners.get(m[1]!);
if (!targets || !targets.length) continue;
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets) {
@@ -2845,6 +2854,7 @@ async function mediatrDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield)
const content = ctx.readFile(file);
if (!content || (!content.includes('.Send(') && !content.includes('.Publish('))) continue;
const safe = stripCommentsForRegex(content, 'csharp');
+ const lineAt = makeLineAt(safe, 1);
const safeLines = safe.split('\n');
const nodesInFile = ctx.getNodesInFile(file);
MEDIATR_DISPATCH_RE.lastIndex = 0;
@@ -2852,7 +2862,7 @@ async function mediatrDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield)
let added = 0;
while ((m = MEDIATR_DISPATCH_RE.exec(safe)) && added < MEDIATR_FANOUT_CAP) {
if (!MEDIATR_RECEIVER_RE.test(m[1]!)) continue; // not a mediator (MessagingCenter, HttpClient, …)
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
const type = resolveMediatrArgType(m[2]!, safeLines, disp.startLine, line);
@@ -2943,12 +2953,13 @@ async function sidekiqDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield)
const content = ctx.readFile(file);
if (!content || !/\.perform_(?:async|in|at)\b/.test(content)) continue;
const safe = stripCommentsForRegex(content, 'ruby');
+ const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
SIDEKIQ_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
let added = 0;
while ((m = SIDEKIQ_DISPATCH_RE.exec(safe)) && added < SIDEKIQ_FANOUT_CAP) {
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
const target = resolve(m[1]!);
@@ -3296,6 +3307,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
const content = ctx.readFile(file);
if (!content || !/[A-Z][A-Za-z0-9_@]*:[a-z]/.test(content)) continue;
const safe = stripCommentsForRegex(content, 'erlang');
+ const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
ERLANG_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
@@ -3310,7 +3322,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
const behaviour = behaviours[0]!;
const targets = targetsOf(behaviour, fn);
if (targets.length === 0 || targets.length > ERLANG_BEHAVIOUR_FANOUT_CAP) continue;
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets) {
@@ -3450,6 +3462,7 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P
const content = ctx.readFile(file);
if (!content || !content.includes('event(')) continue;
const safe = stripCommentsForRegex(content, 'php');
+ const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
LARAVEL_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
@@ -3457,7 +3470,7 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P
while ((m = LARAVEL_DISPATCH_RE.exec(safe)) && added < LARAVEL_FANOUT_CAP) {
const targets = listeners.get(phpSimpleName(m[1]!));
if (!targets) continue;
- const line = safe.slice(0, m.index).split('\n').length;
+ const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets.values()) {
@@ -3709,7 +3722,17 @@ export async function synthesizeCallbackEdges(
// fan out across its read-only workers and the per-pass wall-clock comes from
// the worker; a pass that fails on a worker falls back to running on the main
// thread, so a worker crash isolates to a retry instead of failing synthesis.
- const passEdges: Edge[][] = new Array(SYNTH_PASSES.length).fill(NONE);
+ const passEdges: Array = new Array(SYNTH_PASSES.length);
+ const merged: Edge[] = [];
+ const seen = new Set();
+ const mergeEdges = (edges: Edge[]): void => {
+ for (const e of edges) {
+ const key = `${e.source}>${e.target}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ merged.push(e);
+ }
+ };
const markPass = (label: string, dt: number): void => {
if (process.env.CODEGRAPH_SYNTH_TIMINGS && (dt > 250 || process.env.CODEGRAPH_SYNTH_TIMINGS === 'all')) {
console.error(`[synth-timing] ${label}: ${dt}ms`);
@@ -3717,10 +3740,12 @@ export async function synthesizeCallbackEdges(
passesDone++;
emit(passesDone);
};
- const runPassOnMain = async (i: number): Promise => {
+ const runPassOnMain = async (i: number, retainForOrderedMerge: boolean): Promise => {
const pass = SYNTH_PASSES[i]!;
const t0 = Date.now();
- passEdges[i] = await pass.run(queries, ctx, yieldToLoop, subProgress);
+ const edges = await pass.run(queries, ctx, yieldToLoop, subProgress);
+ if (retainForOrderedMerge) passEdges[i] = edges;
+ else mergeEdges(edges);
await yieldToLoop();
markPass(pass.name, Date.now() - t0);
};
@@ -3760,23 +3785,25 @@ export async function synthesizeCallbackEdges(
}
// Worker-side failure (crash, OOM, unknown pass after a version
// mismatch): retry this one pass on the main thread.
- await runPassOnMain(i);
+ await runPassOnMain(i, true);
}
})
);
} else {
for (const i of gatedIn) {
- await runPassOnMain(i);
+ // Merge before starting the next pass so the just-produced array can be
+ // reclaimed instead of retaining every pass result until the end.
+ await runPassOnMain(i, false);
}
}
- const merged: Edge[] = [];
- const seen = new Set();
- for (const e of passEdges.flat()) {
- const key = `${e.source}>${e.target}`;
- if (seen.has(key)) continue;
- seen.add(key);
- merged.push(e);
+ markT.t = Date.now();
+ if (pool && gatedIn.length > 1) {
+ // Worker results arrive out of order, so merge them in registry order to
+ // preserve which duplicate edge wins while releasing each array promptly.
+ for (const edges of passEdges) {
+ if (edges) mergeEdges(edges);
+ }
}
__mark('dedupe-merge');
// Chunked insert with yields: on the Linux kernel the merged synthesized
diff --git a/src/resolution/index.ts b/src/resolution/index.ts
index 01f615b28..6cb5ac82d 100644
--- a/src/resolution/index.ts
+++ b/src/resolution/index.ts
@@ -22,12 +22,14 @@ import { ResolverPool, minRefsForPool } from './resolver-pool';
import { detectFrameworks } from './frameworks';
import { synthesizeCallbackEdges } from './callback-synthesizer';
import { createYielder, type MaybeYield } from './cooperative-yield';
+import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits';
import { loadProjectAliases, type AliasMap } from './path-aliases';
import { loadGoModule, type GoModule } from './go-module';
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
import { logDebug } from '../errors';
import type { ReExport } from './types';
import { LRUCache } from './lru-cache';
+import { memoryBudgetBytes } from './memory-budget';
/** Node kinds that can declare supertypes (extends/implements). */
const SUPERTYPE_BEARING_KINDS = new Set([
@@ -288,8 +290,15 @@ export class ReferenceResolver {
// The content cache is heavier (full file text), so we give it a
// smaller budget than the metadata caches.
const contentLimit = Math.max(64, Math.floor(limit / 5));
+ const contentBudget = Math.max(
+ 8 * 1024 * 1024,
+ Math.min(64 * 1024 * 1024, Math.floor(memoryBudgetBytes() * 0.02))
+ );
this.nodeCache = new LRUCache(limit);
- this.fileCache = new LRUCache(contentLimit);
+ this.fileCache = new LRUCache(contentLimit, {
+ maxWeight: Math.floor(contentBudget / 4),
+ weightOf: (value) => value === null ? 1 : Math.max(64, value.length * 2),
+ });
this.importMappingCache = new LRUCache(limit);
this.reExportCache = new LRUCache(limit);
this.nameCache = new LRUCache(limit);
@@ -297,7 +306,12 @@ export class ReferenceResolver {
this.qualifiedNameCache = new LRUCache(limit);
// Split-lines arrays are heavier than content strings; refs arrive
// file-ordered, so a small cache still hits nearly always.
- this.fileLinesCache = new LRUCache(contentLimit);
+ this.fileLinesCache = new LRUCache(contentLimit, {
+ maxWeight: Math.floor(contentBudget * 3 / 4),
+ weightOf: (value) => value === null
+ ? 1
+ : Math.max(64, value.length * 8 + value.reduce((sum, line) => sum + line.length * 2, 0)),
+ });
this.methodMatchCache = new LRUCache(limit);
this.context = this.createContext();
@@ -417,6 +431,14 @@ export class ReferenceResolver {
}
const fullPath = path.join(this.projectRoot, filePath);
try {
+ // Import resolvers may follow package metadata to an archive (`file:*.har`,
+ // for example). Reject anything extraction would not accept before UTF-8
+ // decoding can multiply a large binary blob into gigabytes of V8 heap.
+ const stats = fs.statSync(fullPath);
+ if (!stats.isFile() || stats.size > MAX_SOURCE_FILE_SIZE_BYTES) {
+ this.fileCache.set(filePath, null);
+ return null;
+ }
const content = fs.readFileSync(fullPath, 'utf-8');
this.fileCache.set(filePath, content);
return content;
diff --git a/src/resolution/lru-cache.ts b/src/resolution/lru-cache.ts
index 2a597ddbe..fdc3f7e9d 100644
--- a/src/resolution/lru-cache.ts
+++ b/src/resolution/lru-cache.ts
@@ -13,13 +13,25 @@
*/
export class LRUCache {
private readonly max: number;
+ private readonly maxWeight: number | null;
+ private readonly weightOf: ((value: V, key: K) => number) | null;
private readonly store = new Map();
+ private readonly weights = new Map();
+ private totalWeight = 0;
- constructor(max: number) {
+ constructor(
+ max: number,
+ opts: { maxWeight: number; weightOf: (value: V, key: K) => number } | null = null
+ ) {
if (!Number.isFinite(max) || max <= 0) {
throw new Error(`LRUCache max must be a positive finite number, got ${max}`);
}
+ if (opts && (!Number.isFinite(opts.maxWeight) || opts.maxWeight <= 0)) {
+ throw new Error(`LRUCache maxWeight must be a positive finite number, got ${opts.maxWeight}`);
+ }
this.max = Math.floor(max);
+ this.maxWeight = opts ? Math.floor(opts.maxWeight) : null;
+ this.weightOf = opts?.weightOf ?? null;
}
get size(): number {
@@ -45,18 +57,35 @@ export class LRUCache {
set(key: K, value: V): void {
if (this.store.has(key)) {
+ this.totalWeight -= this.weights.get(key) ?? 0;
this.store.delete(key);
- } else if (this.store.size >= this.max) {
+ this.weights.delete(key);
+ }
+
+ const weight = this.weightOf ? Math.max(0, Math.ceil(this.weightOf(value, key))) : 0;
+ if (this.maxWeight !== null && weight > this.maxWeight) return;
+
+ while (
+ this.store.size >= this.max ||
+ (this.maxWeight !== null && this.totalWeight + weight > this.maxWeight)
+ ) {
// Evict the oldest entry — first key in iteration order.
const oldest = this.store.keys().next().value;
- if (oldest !== undefined) {
- this.store.delete(oldest);
- }
+ if (oldest === undefined) break;
+ this.totalWeight -= this.weights.get(oldest) ?? 0;
+ this.weights.delete(oldest);
+ this.store.delete(oldest);
}
this.store.set(key, value);
+ if (this.maxWeight !== null) {
+ this.weights.set(key, weight);
+ this.totalWeight += weight;
+ }
}
clear(): void {
this.store.clear();
+ this.weights.clear();
+ this.totalWeight = 0;
}
}
diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts
index fed6ea608..5cfeb3f1e 100644
--- a/src/sync/watcher.ts
+++ b/src/sync/watcher.ts
@@ -514,7 +514,8 @@ export class FileWatcher {
if (isInotifyWatchExhaustion(err)) {
this.warnInotifyLimit({ error: String(err), dir });
}
- this.unwatchDir(dir);
+ this.unwatchSubtree(dir);
+ this.maybeScheduleForRemovedDir(normalizePath(path.relative(this.projectRoot, dir)));
});
this.dirWatchers.set(dir, w);
@@ -554,7 +555,15 @@ export class FileWatcher {
return;
}
} catch {
- // deleted/inaccessible — treat as a normal change below
+ // If this path used to own a watch, it was a directory. Linux often emits
+ // only the parent's rename event and no watcher error for the removed
+ // subtree, so close every descendant watch here and force a scan-diff.
+ if (this.unwatchSubtree(full) > 0) {
+ this.needsFullScan = true;
+ this.scheduleSync();
+ return;
+ }
+ // Deleted/inaccessible ordinary file — treat as a normal change below.
}
this.handleChange(normalizePath(path.relative(this.projectRoot, full)));
@@ -621,17 +630,21 @@ export class FileWatcher {
this.scheduleSync();
}
- /** Close and forget the watch for a directory that errored/was removed. */
- private unwatchDir(dir: string): void {
- const w = this.dirWatchers.get(dir);
- if (w) {
+ /** Close and forget a removed directory and every watched descendant. */
+ private unwatchSubtree(dir: string): number {
+ let removed = 0;
+ const prefix = dir.endsWith(path.sep) ? dir : dir + path.sep;
+ for (const [watchedDir, w] of [...this.dirWatchers]) {
+ if (watchedDir !== dir && !watchedDir.startsWith(prefix)) continue;
try {
w.close();
} catch {
/* already closed */
}
- this.dirWatchers.delete(dir);
+ this.dirWatchers.delete(watchedDir);
+ removed++;
}
+ return removed;
}
/** Our own dirs are always ignored, regardless of .gitignore. */
diff --git a/src/types.ts b/src/types.ts
index 186f57adc..abbd2cd17 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -399,6 +399,17 @@ export interface Subgraph {
* for graph traversals that don't run the search-ranking path.
*/
confidence?: 'high' | 'low';
+
+ /** True when a traversal stopped at its node/edge safety budget. */
+ truncated?: boolean;
+}
+
+/** Safety budgets for impact traversal on high-fanout graphs. */
+export interface ImpactOptions {
+ /** Maximum nodes retained, including the focal node. Default: 10,000. */
+ maxNodes?: number;
+ /** Maximum edges retained. Default: 50,000. */
+ maxEdges?: number;
}
/**
]