diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b2d07fb3..2e7b03773 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,13 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
+- The MCP server now finds your project when it's launched from a workspace folder above it: if the launch directory has no index of its own but exactly one indexed project sits below it (a repo container, an agent workspace, a monorepo root), that project becomes the session's default — live file watching and the shared daemon included — instead of every tool call failing until a `projectPath` or `--path` is supplied. Thanks @nakisen. (#1606)
+
+- When no project can be resolved at all, the MCP server now says so instead of starting silently: a startup log line names the directory it searched, and tool calls list the indexed sub-projects it can see nearby so you can pass one as `projectPath`. Previously the server looked healthy from the outside while every tool quietly had no project to answer from. Thanks @nakisen. (#1607)
+
+- Incremental sync now applies WAL backpressure during changed-file storage and batched reference resolution, keeping long-lived readers from allowing the WAL to grow past its configured cap on large projects. (#1539)
+- 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.
- Indexing no longer hangs on a Swift Vapor project containing a call with a long argument list. A single `.get(...)`-style call with many labeled arguments and no `use:` handler — the shape generated request builders produce — could stall `codegraph index`, `codegraph sync`, and the MCP server indefinitely. Route detection now handles such files in milliseconds, and every previously-recognized route shape still parses exactly as before. Thanks @maxmilian. (#1544) (Swift)
- `codegraph status` now sees new files inside brand-new directories. Git reports an entirely-untracked directory as a single collapsed entry, so source files created there — a freshly scaffolded `frontend/`, for example — were missing from the pending-changes report, which could claim everything was up to date while those files had not yet been indexed. Thanks @maxmilian. (#1213)
diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts
index 6bc48032e..dccb81c28 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';
@@ -9517,6 +9517,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__/mcp-subproject-adoption.test.ts b/__tests__/mcp-subproject-adoption.test.ts
new file mode 100644
index 000000000..39abac038
--- /dev/null
+++ b/__tests__/mcp-subproject-adoption.test.ts
@@ -0,0 +1,188 @@
+/**
+ * MCP workspace sub-project adoption + no-default diagnostics (#1606, #1607).
+ *
+ * When an MCP host launches the server from a workspace root whose indexed
+ * projects live in CHILD directories (a repo container, a monorepo root), the
+ * upward walk finds nothing. The server now runs the same bounded down-scan
+ * the front-load hook uses:
+ * - exactly ONE indexed sub-project → adopted as the session's default;
+ * - zero or several → no default, but the state is SAID:
+ * stderr names what was searched/found, and tool calls list the indexed
+ * sub-projects so the agent can pass one as `projectPath`;
+ * - non-workspace base (no manifest, no .git) → no scan at all.
+ *
+ * Same real-subprocess harness as mcp-roots.test.ts — no mocking.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
+ // --no-watch keeps the test deterministic; CODEGRAPH_NO_DAEMON keeps the
+ // session in direct mode so no detached daemon outlives the test.
+ return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], {
+ cwd,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
+ }) as ChildProcessWithoutNullStreams;
+}
+
+function collectMessages(child: ChildProcessWithoutNullStreams): Array> {
+ const messages: Array> = [];
+ let buf = '';
+ child.stdout.on('data', (chunk) => {
+ buf += chunk.toString('utf8');
+ let idx;
+ while ((idx = buf.indexOf('\n')) !== -1) {
+ const line = buf.slice(0, idx).trim();
+ buf = buf.slice(idx + 1);
+ if (!line) continue;
+ try { messages.push(JSON.parse(line)); } catch { /* ignore non-JSON */ }
+ }
+ });
+ return messages;
+}
+
+function collectStderr(child: ChildProcessWithoutNullStreams): { text: () => string } {
+ let buf = '';
+ child.stderr.on('data', (chunk) => { buf += chunk.toString('utf8'); });
+ return { text: () => buf };
+}
+
+function waitForMessage(
+ messages: ReadonlyArray>,
+ predicate: (m: Record) => boolean,
+ timeoutMs: number,
+): Promise> {
+ return new Promise((resolve, reject) => {
+ const started = Date.now();
+ const tick = () => {
+ const hit = messages.find(predicate);
+ if (hit) return resolve(hit);
+ if (Date.now() - started > timeoutMs) {
+ return reject(new Error(`Timed out. Messages so far: ${JSON.stringify(messages)}`));
+ }
+ setTimeout(tick, 20);
+ };
+ tick();
+ });
+}
+
+function send(child: ChildProcessWithoutNullStreams, msg: object): void {
+ child.stdin.write(JSON.stringify(msg) + '\n');
+}
+
+const CLIENT_INFO = { name: 'test', version: '0.0.0' };
+
+/** Create ws/ with one source file and an initialized .codegraph/. */
+async function makeIndexedChild(ws: string, name: string): Promise {
+ const dir = path.join(ws, name);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'a.ts'), `export function hello_${name}() { return 1; }\n`);
+ const cg = await CodeGraph.init(dir);
+ cg.close();
+ return dir;
+}
+
+/** initialize (no rootUri, no roots capability) → initialized → codegraph_status. */
+async function driveStatusCall(
+ child: ChildProcessWithoutNullStreams,
+ messages: Array>,
+): Promise<{ initResult: Record; statusText: string }> {
+ send(child, {
+ jsonrpc: '2.0', id: 0, method: 'initialize',
+ params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: CLIENT_INFO },
+ });
+ const initResult = await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000);
+ send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
+ send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
+ const resp = await waitForMessage(messages, (m) => m.id === 1, 10000);
+ return { initResult, statusText: resp.result.content[0].text as string };
+}
+
+describe('MCP workspace sub-project adoption (#1606) + no-default diagnostics (#1607)', () => {
+ let ws: string;
+ let child: ChildProcessWithoutNullStreams | null = null;
+
+ beforeEach(() => {
+ ws = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-ws-'));
+ });
+
+ afterEach(() => {
+ if (child && !child.killed) {
+ child.kill('SIGKILL');
+ child = null;
+ }
+ fs.rmSync(ws, { recursive: true, force: true });
+ });
+
+ it('adopts the single indexed sub-project below a workspace root as the default project', async () => {
+ fs.mkdirSync(path.join(ws, '.git')); // workspace marker — no manifest needed
+ await makeIndexedChild(ws, 'service-a');
+
+ child = spawnServer(ws);
+ const messages = collectMessages(child);
+ const stderr = collectStderr(child);
+
+ const { initResult, statusText } = await driveStatusCall(child, messages);
+
+ // The default project works without any projectPath.
+ expect(statusText).toContain('CodeGraph Status');
+ expect(statusText).not.toContain('No CodeGraph project is loaded');
+ // The adoption is announced on stderr (#1607 discoverability).
+ expect(stderr.text()).toContain('adopted the single indexed sub-project');
+ expect(stderr.text()).toContain('service-a');
+ // Instructions match what the engine adopted: the FULL single-project
+ // playbook, not the per-project variant.
+ const instructions = initResult.result.instructions as string;
+ expect(instructions).not.toContain('per-project; pass projectPath');
+ }, 20000);
+
+ it('lists several indexed sub-projects instead of adopting one, in stderr and in tool responses', async () => {
+ fs.mkdirSync(path.join(ws, '.git'));
+ await makeIndexedChild(ws, 'service-a');
+ await makeIndexedChild(ws, 'service-b');
+
+ child = spawnServer(ws);
+ const messages = collectMessages(child);
+ const stderr = collectStderr(child);
+
+ const { initResult, statusText } = await driveStatusCall(child, messages);
+
+ // No default was adopted — ambiguous — but the state is said, not silent.
+ expect(statusText).toContain('No CodeGraph project is loaded');
+ // Protocol-reachable listing (#1607): the tool response names what IS there.
+ expect(statusText).toContain('Indexed sub-projects were found below it');
+ expect(statusText).toContain('service-a');
+ expect(statusText).toContain('service-b');
+ expect(statusText).toContain('projectPath');
+ // stderr carries the same facts for the host's log.
+ expect(stderr.text()).toContain('no default project, live sync disabled');
+ expect(stderr.text()).toContain('Indexed sub-projects found:');
+ // Ambiguous root → per-project instructions variant.
+ const instructions = initResult.result.instructions as string;
+ expect(instructions).toContain('per-project; pass projectPath');
+ }, 20000);
+
+ it('does not scan below a base that is not a workspace (no manifest, no .git)', async () => {
+ // NO .git and no manifest at ws — the gate must keep the scan off even
+ // though an indexed child exists.
+ await makeIndexedChild(ws, 'service-a');
+
+ child = spawnServer(ws);
+ const messages = collectMessages(child);
+ const stderr = collectStderr(child);
+
+ const { statusText } = await driveStatusCall(child, messages);
+
+ expect(statusText).toContain('No CodeGraph project is loaded');
+ expect(statusText).not.toContain('Indexed sub-projects were found below it');
+ expect(stderr.text()).toContain('no default project, live sync disabled');
+ expect(stderr.text()).not.toContain('Indexed sub-projects found:');
+ }, 20000);
+});
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__/wal-deferral.test.ts b/__tests__/wal-deferral.test.ts
index f88a84ee6..67da3fb34 100644
--- a/__tests__/wal-deferral.test.ts
+++ b/__tests__/wal-deferral.test.ts
@@ -9,7 +9,7 @@
* the valve's trigger/dedupe/backpressure logic, and the end-to-end indexAll
* behavior (identical graph with and without deferral; interval restored).
*/
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -193,6 +193,19 @@ function writeFixtureProject(): void {
}
}
+async function seedPendingRefs(cg: CodeGraph): Promise {
+ const raw = (cg as unknown as { db: DatabaseConnection }).db.getDb();
+ const node = raw.prepare("SELECT id, file_path FROM nodes WHERE kind = 'function' LIMIT 1").get() as
+ | { id: string; file_path: string }
+ | undefined;
+ expect(node).toBeDefined();
+ const ins = raw.prepare(
+ "INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status) VALUES (?, ?, 'calls', 1, 0, ?, 'typescript', 'pending')"
+ );
+ ins.run(node!.id, 'helper0', node!.file_path);
+ ins.run(node!.id, 'helper1', node!.file_path);
+}
+
describe('indexAll WAL deferral end-to-end', () => {
it('produces the same graph with and without deferral, and restores the interval', async () => {
@@ -298,6 +311,35 @@ describe('sync WAL deferral end-to-end (#1248)', () => {
delete process.env.CODEGRAPH_NO_WAL_DEFER;
}
});
+
+ it('applies WAL backpressure during changed-file storage and orphan resolution (#1539)', async () => {
+ writeFixtureProject();
+ const cg = CodeGraph.initSync(tmpDir);
+ await cg.indexAll();
+ const backpressure = vi
+ .spyOn(WalCheckpointValve.prototype, 'backpressure')
+ .mockReturnValue(null);
+
+ try {
+ fs.writeFileSync(
+ path.join(tmpDir, 'src', 'mod0.ts'),
+ `export function fn0(x: number): number { return helper0(x) + 100; }\n` +
+ `function helper0(x: number): number { return x * 100; }\n`
+ );
+ const changed = await cg.sync();
+ expect(changed.filesModified).toBe(1);
+ expect(backpressure).toHaveBeenCalled();
+
+ backpressure.mockClear();
+ await seedPendingRefs(cg);
+ const recovered = await cg.sync();
+ expect(recovered.filesAdded + recovered.filesModified + recovered.filesRemoved).toBe(0);
+ expect(backpressure).toHaveBeenCalled();
+ } finally {
+ backpressure.mockRestore();
+ await cg.close();
+ }
+ });
});
describe('resolution-phase WAL backpressure plumbing (§7a.1)', () => {
@@ -308,19 +350,6 @@ describe('resolution-phase WAL backpressure plumbing (§7a.1)', () => {
// 22GB WAL on a 4.6GB DB. These pin that the batch loop (a) calls the hook
// at the pool-idle boundary and (b) actually parks on a returned promise.
- async function seedPendingRefs(cg: CodeGraph): Promise {
- const raw = (cg as unknown as { db: DatabaseConnection }).db.getDb();
- const node = raw.prepare("SELECT id, file_path FROM nodes WHERE kind = 'function' LIMIT 1").get() as
- | { id: string; file_path: string }
- | undefined;
- expect(node).toBeDefined();
- const ins = raw.prepare(
- "INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status) VALUES (?, ?, 'calls', 1, 0, ?, 'typescript', 'pending')"
- );
- ins.run(node!.id, 'helper0', node!.file_path);
- ins.run(node!.id, 'helper1', node!.file_path);
- }
-
it('calls the backpressure hook once per settled batch', async () => {
writeFixtureProject();
const cg = CodeGraph.initSync(tmpDir);
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 a0f8bc541..08a7b9219 100644
--- a/src/db/queries.ts
+++ b/src/db/queries.ts
@@ -1816,8 +1816,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];
@@ -1831,6 +1831,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);
}
@@ -1845,10 +1850,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/directory.ts b/src/directory.ts
index fd4a1aa0b..ff5c3b842 100644
--- a/src/directory.ts
+++ b/src/directory.ts
@@ -233,6 +233,65 @@ export function findIndexedSubprojectRoots(
return out;
}
+/** Result of {@link resolveServerRoot}. */
+export interface ServerRootResolution {
+ /** The project root to serve as the default, or null when none resolved. */
+ root: string | null;
+ /** True when `root` was adopted from the down-scan rather than the up-walk. */
+ viaSubScan: boolean;
+ /**
+ * Indexed sub-projects the down-scan saw when it ran but could NOT adopt
+ * (zero or several candidates). Empty when the up-walk resolved or the scan
+ * was skipped. Callers surface these so "no default project" errors can say
+ * what IS reachable (#1607).
+ */
+ candidates: string[];
+}
+
+/**
+ * Whether `base` is a plausible workspace root for the sub-project down-scan.
+ * Mirrors `planFrontload`'s manifest gate, widened to accept a bare `.git`
+ * entry — the #1606 shape is a workspace container holding only agent config
+ * and a `.git`, with every build manifest living in the indexed children. The
+ * user's home directory and the filesystem root are never eligible: a stray
+ * manifest there must not turn server startup into a scan that could adopt an
+ * unrelated project (#1454 documents that failure mode for the prompt-hook).
+ */
+function eligibleForSubprojectScan(base: string): boolean {
+ if (base === path.parse(base).root) return false;
+ let home: string | null = null;
+ try { home = os.homedir(); } catch { home = null; }
+ if (home && (base === home || base === path.resolve(home))) return false;
+ if (looksLikeProjectRoot(base)) return true;
+ return fs.existsSync(path.join(base, '.git'));
+}
+
+/**
+ * Resolve the project root an MCP server should serve as its DEFAULT project
+ * (#1606). Up-walk first (`findNearestCodeGraphRoot` — the common case, and
+ * cheap). When nothing is indexed at or above `searchFrom`, run the bounded
+ * sub-project down-scan `planFrontload` already uses, behind the workspace
+ * gate above: EXACTLY ONE indexed sub-project is unambiguous and is adopted
+ * as the root; zero or several yield no root, with the candidates carried so
+ * the caller can name them instead of failing silently (#1607).
+ *
+ * `opts.subprojectScan: false` skips the down-scan entirely (the per-tool-call
+ * retry path throttles it; the up-walk always runs).
+ */
+export function resolveServerRoot(
+ searchFrom: string,
+ opts: { subprojectScan?: boolean } = {},
+): ServerRootResolution {
+ const up = findNearestCodeGraphRoot(searchFrom);
+ if (up) return { root: up, viaSubScan: false, candidates: [] };
+ if (opts.subprojectScan === false) return { root: null, viaSubScan: false, candidates: [] };
+ const base = path.resolve(searchFrom);
+ if (!eligibleForSubprojectScan(base)) return { root: null, viaSubScan: false, candidates: [] };
+ const subs = findIndexedSubprojectRoots(base);
+ if (subs.length === 1) return { root: subs[0]!, viaSubScan: true, candidates: subs };
+ return { root: null, viaSubScan: false, candidates: subs };
+}
+
/**
* Unicode-aware word-boundary emulation for the keyword lists below. JS's `\b`
* is ASCII-only — it fires only at `[A-Za-z0-9_]` edges — so it can never bound
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 45807c5c6..1a2c41d85 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
@@ -1916,18 +1910,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',
@@ -2255,14 +2249,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',
@@ -2659,7 +2653,13 @@ export class ExtractionOrchestrator {
* set is not exactly known (directory removals, event overflow): the full
* scan-diff remains the ground truth those cases need (#1285).
*/
- scopedPaths?: string[]
+ scopedPaths?: string[],
+ /**
+ * Writer-side WAL pressure valve. Called after every changed file is
+ * stored, when no extraction transaction is open, so a checkpoint can
+ * safely catch up before the next file grows the WAL further.
+ */
+ backpressure?: () => Promise | null
): Promise {
await initGrammars(); // Initialize WASM runtime (grammars loaded lazily below)
const startTime = Date.now();
@@ -2843,6 +2843,9 @@ export class ExtractionOrchestrator {
const result = await this.indexFile(filePath);
nodesUpdated += result.nodes.length;
+
+ const pause = backpressure?.();
+ if (pause) await pause;
}
// Names whose definition set this sync changed: a `file\0name` pair present
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 90397c55c..e52a340ad 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';
@@ -815,7 +816,8 @@ export class CodeGraph {
try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
})();
- const result = await this.orchestrator.sync(options.onProgress, options.paths);
+ const backpressure = walValve ? () => walValve!.backpressure() : undefined;
+ const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure);
// Fold the store phase's WAL BEFORE the post-store reads below
// (resolution reads on the main thread) — same rationale as
@@ -913,7 +915,8 @@ export class CodeGraph {
current: done,
total: totalPasses,
});
- }
+ },
+ backpressure
);
}
}
@@ -979,7 +982,8 @@ export class CodeGraph {
current: done,
total: totalPasses,
});
- }
+ },
+ backpressure
);
}
@@ -1740,8 +1744,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/engine.ts b/src/mcp/engine.ts
index 9ee132d64..8f2e5b6ef 100644
--- a/src/mcp/engine.ts
+++ b/src/mcp/engine.ts
@@ -11,8 +11,9 @@
*/
import * as os from 'os';
+import * as path from 'path';
import type CodeGraph from '../index';
-import { findNearestCodeGraphRoot } from '../directory';
+import { resolveServerRoot } from '../directory';
import { watchDisabledReason } from '../sync';
import { ToolHandler } from './tools';
import { QueryPool, resolvePoolSize } from './query-pool';
@@ -26,6 +27,9 @@ import { QueryPool, resolvePoolSize } from './query-pool';
const loadCodeGraph = (): typeof import('../index').default =>
(require('../index') as typeof import('../index')).default;
+/** How often the per-tool-call retry may re-run the sub-project down-scan. */
+const RETRY_SUBSCAN_TTL_MS = 5_000;
+
export interface MCPEngineOptions {
/**
* Whether to start the file watcher when initializing. Daemon and direct
@@ -59,6 +63,9 @@ export class MCPEngine {
private projectPath: string | null = null;
// Set on first `ensureInitialized` so subsequent sessions don't redo work.
private initPromise: Promise | null = null;
+ // Throttle for the retry path's sub-project down-scan (#1606) — the scan is
+ // bounded but shouldn't run on every tool call in the no-default state.
+ private lastRetrySubScanAt = 0;
private watcherStarted = false;
private opts: Required;
private closed = false;
@@ -158,8 +165,20 @@ export class MCPEngine {
if (this.closed) return;
if (this.toolHandler.hasDefaultCodeGraph()) return;
this.toolHandler.setDefaultProjectHint(searchFrom);
- const resolvedRoot = findNearestCodeGraphRoot(searchFrom);
+ // Same resolution `doInitialize` used: up-walk, then the bounded workspace
+ // down-scan (#1606) — this retry is exactly the path that picks up a
+ // project (root or child) `codegraph init`'d after the server started. The
+ // down-scan is throttled so the persistent no-default state doesn't pay a
+ // directory walk on every tool call; the up-walk always runs.
+ const scanDue = Date.now() - this.lastRetrySubScanAt >= RETRY_SUBSCAN_TTL_MS;
+ const res = resolveServerRoot(searchFrom, { subprojectScan: scanDue });
+ if (scanDue) {
+ this.lastRetrySubScanAt = Date.now();
+ if (!res.root) this.toolHandler.setKnownSubprojects(res.candidates, searchFrom);
+ }
+ const resolvedRoot = res.root;
if (!resolvedRoot) return;
+ if (res.viaSubScan) this.logSubprojectAdoption(searchFrom, resolvedRoot);
try {
// Close any previously failed instance to avoid leaking resources.
if (this.cg) {
@@ -201,12 +220,32 @@ export class MCPEngine {
private async doInitialize(searchFrom: string): Promise {
this.toolHandler.setDefaultProjectHint(searchFrom);
- const resolvedRoot = findNearestCodeGraphRoot(searchFrom);
+ // Up-walk first; when nothing is indexed at or above searchFrom, a bounded
+ // down-scan may adopt a SINGLE indexed sub-project as the default (#1606 —
+ // the workspace-container shape where only children are indexed). Zero or
+ // several candidates → no default project, but SAY so (#1607): the silent
+ // variant of this state read as "CodeGraph is broken" and was diagnosable
+ // only by knowing to look for a missing ~/.codegraph/daemons/ entry.
+ const res = resolveServerRoot(searchFrom);
+ const resolvedRoot = res.root;
if (!resolvedRoot) {
- // No .codegraph/ above searchFrom. Sessions may still discover one later via roots/list
+ // Sessions may still discover a project later via roots/list, and the
+ // per-call retry re-resolves — this state is recoverable, hence stderr
+ // (not a failure) + candidates surfaced through the tool-call error.
this.projectPath = searchFrom;
+ this.toolHandler.setKnownSubprojects(res.candidates, searchFrom);
+ process.stderr.write(
+ `[CodeGraph MCP] No .codegraph/ at or above ${searchFrom}: no default project, live sync disabled.\n`
+ );
+ if (res.candidates.length > 0) {
+ const rels = res.candidates.map((c) => path.relative(searchFrom, c) || '.');
+ process.stderr.write(
+ `[CodeGraph MCP] Indexed sub-projects found: ${rels.join(', ')}. Pass \`projectPath\` per call, or launch with --path.\n`
+ );
+ }
return;
}
+ if (res.viaSubScan) this.logSubprojectAdoption(searchFrom, resolvedRoot);
this.projectPath = resolvedRoot;
try {
@@ -221,6 +260,14 @@ export class MCPEngine {
}
}
+ /** One stderr line when the default project came from the down-scan (#1606). */
+ private logSubprojectAdoption(searchFrom: string, root: string): void {
+ const rel = path.relative(searchFrom, root) || root;
+ process.stderr.write(
+ `[CodeGraph MCP] No .codegraph/ at ${searchFrom}; adopted the single indexed sub-project ${rel} as the default project.\n`
+ );
+ }
+
/**
* Start file watching on the active CodeGraph instance. Idempotent — the
* watcher is per-engine, not per-session, which is why the daemon path
diff --git a/src/mcp/index.ts b/src/mcp/index.ts
index 971121054..3f57024d2 100644
--- a/src/mcp/index.ts
+++ b/src/mcp/index.ts
@@ -37,7 +37,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawn, StdioOptions } from 'child_process';
-import { findNearestCodeGraphRoot, getCodeGraphDir } from '../directory';
+import { resolveServerRoot, getCodeGraphDir } from '../directory';
import { StdioTransport } from './transport';
import { MCPEngine } from './engine';
import { MCPSession } from './session';
@@ -150,6 +150,12 @@ export function watchdogProgressPaths(root: string | null): { progressPaths?: st
* that case the caller must run in direct mode, since the daemon lockfile
* and socket both live under `.codegraph/`.
*
+ * Uses the same resolution as the engine (#1606): up-walk first, then the
+ * bounded workspace down-scan that adopts a SINGLE indexed sub-project. A
+ * workspace root above one indexed child therefore gets the shared daemon
+ * (one watcher, one writer, keyed on the child) instead of a direct-mode
+ * server per host.
+ *
* The result is canonicalized with `realpathSync` so every client converges on
* the same socket/lock path regardless of how it expressed the path: a client
* launched with cwd under a symlink (e.g. macOS `/var` → `/private/var`, where
@@ -159,7 +165,7 @@ export function watchdogProgressPaths(root: string | null): { progressPaths?: st
*/
function resolveDaemonRoot(explicitPath: string | null): string | null {
const candidate = explicitPath ?? process.cwd();
- const root = findNearestCodeGraphRoot(candidate);
+ const root = resolveServerRoot(candidate).root;
if (!root) return null;
try { return fs.realpathSync(root); } catch { return root; }
}
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/session.ts b/src/mcp/session.ts
index 866e0014a..1d5bd79c3 100644
--- a/src/mcp/session.ts
+++ b/src/mcp/session.ts
@@ -18,7 +18,7 @@ import { MCPEngine } from './engine';
import { tools } from './tools';
import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_NO_ROOT_INDEX } from './server-instructions';
import { CodeGraphPackageVersion } from './version';
-import { findNearestCodeGraphRoot } from '../directory';
+import { resolveServerRoot } from '../directory';
import { getTelemetry, ClientInfo } from '../telemetry';
import { getUpdateNotice } from '../upgrade/update-check';
import { ExploreSessionState } from './explore-session-state';
@@ -230,18 +230,23 @@ export class MCPSession {
explicitPath = this.explicitProjectPath;
}
- // Pick the instructions variant by the root's index state — a cheap
- // synchronous walk-up (existsSync loop only, no DB open, so the #172
- // respond-fast contract holds). When the root IS indexed, send the full
- // single-project playbook. When it ISN'T, send the per-project variant
- // (tools are still exposed — see handleToolsList): it tells the agent there
- // is no default project and to pass `projectPath` to any project that has a
- // `.codegraph/`. Gating tool AVAILABILITY on whether `./` is indexed was the
- // #964 bug — it broke monorepos (only sub-projects indexed) and never
- // surfaced the tools after a mid-session `codegraph init`. When no explicit
- // path is known yet (roots/list dance pending), cwd is the best predictor of
- // where the default project will resolve.
- const indexed = findNearestCodeGraphRoot(explicitPath ?? process.cwd()) !== null;
+ // Pick the instructions variant by the root's index state — synchronous
+ // and bounded (an existsSync walk-up plus, when that misses, the depth- and
+ // count-bounded workspace down-scan; no DB open, so the #172 respond-fast
+ // contract holds). This is the SAME resolution the engine's doInitialize
+ // runs (#1606), so the variant matches what the engine will actually adopt
+ // — a workspace whose single indexed sub-project becomes the default gets
+ // the full single-project playbook, race-free by construction (both sides
+ // compute it independently; no ordering between handshake and engine init
+ // is assumed). When the root ISN'T indexed (and nothing was adopted), send
+ // the per-project variant (tools are still exposed — see handleToolsList):
+ // it tells the agent there is no default project and to pass `projectPath`
+ // to any project that has a `.codegraph/`. Gating tool AVAILABILITY on
+ // whether `./` is indexed was the #964 bug — it broke monorepos (only
+ // sub-projects indexed) and never surfaced the tools after a mid-session
+ // `codegraph init`. When no explicit path is known yet (roots/list dance
+ // pending), cwd is the best predictor of where the default will resolve.
+ const indexed = resolveServerRoot(explicitPath ?? process.cwd()).root !== null;
// Respond to the handshake BEFORE doing any heavy init — see issue #172.
this.transport.sendResult(request.id, {
diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts
index 4ad7e64b6..b6e3e96c7 100644
--- a/src/mcp/tools.ts
+++ b/src/mcp/tools.ts
@@ -82,7 +82,7 @@ export class NotIndexedError extends Error {}
* retry guidance — abandoning this path is the desired agent reaction.
*/
export class PathRefusalError extends Error {}
-import { resolve as resolvePath } from 'path';
+import { resolve as resolvePath, relative as relativePath } from 'path';
/** Maximum output length to prevent context bloat (characters) */
const MAX_OUTPUT_LENGTH = 15000;
@@ -1314,6 +1314,13 @@ export class ToolHandler {
// The directory the server last searched for a default project. Surfaced in
// the "not initialized" error so users can see why detection missed.
private defaultProjectHint: string | null = null;
+ // Indexed sub-projects the engine's bounded down-scan saw below the search
+ // base when no default project resolved (#1607). Listed in the "not
+ // initialized" error so the fact is reachable through the protocol, not just
+ // the host's stderr capture. Engine-maintained (initial resolve + throttled
+ // retry) — tool calls themselves never scan.
+ private knownSubprojects: string[] = [];
+ private knownSubprojectsBase: string | null = null;
// Per-start-path cache of the git worktree/index mismatch (issue #155). The
// mismatch is a fixed property of (where the request came from → which
// .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
@@ -1411,6 +1418,27 @@ export class ToolHandler {
this.defaultProjectHint = searchedPath;
}
+ /**
+ * Engine-only: record the indexed sub-projects the workspace down-scan saw
+ * when it could not adopt a default project (#1606/#1607). An empty list
+ * clears any previous note.
+ */
+ setKnownSubprojects(roots: string[], base: string): void {
+ this.knownSubprojects = roots;
+ this.knownSubprojectsBase = base;
+ }
+
+ /** One message line naming the indexed sub-projects, or '' when none known. */
+ private formatKnownSubprojects(): string {
+ if (this.knownSubprojects.length === 0) return '';
+ const base = this.knownSubprojectsBase;
+ const rels = this.knownSubprojects.map((r) => (base ? relativePath(base, r) || '.' : r));
+ return (
+ `Indexed sub-projects were found below it: ${rels.join(', ')} — ` +
+ 'pass one of them (absolute, or resolved against that directory) as projectPath.\n'
+ );
+ }
+
/**
* Whether a default CodeGraph instance is available
*/
@@ -1533,6 +1561,7 @@ export class ToolHandler {
throw new NotIndexedError(
'No CodeGraph project is loaded for this session.\n' +
`Searched for a .codegraph/ directory starting from: ${searched}\n` +
+ this.formatKnownSubprojects() +
'Either the server root has no index of its own (e.g. a monorepo where only ' +
"sub-projects are indexed), or the MCP client launched the server outside your " +
'project without reporting the workspace root. Either way, target the project ' +
@@ -2364,23 +2393,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 +6963,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;
}
/**
]