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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading.
- In React and Vue, a tag for a component the file imports from a package, like `<Button>` from antd, `<Typography>` from `@mui/material/Typography`, `<Link>` from react-router-dom or `<Card>` from ant-design-vue, no longer links to a component, function or class in your repository that only shares its name. Before, the tag linked to whatever the repository had by that name, so `codegraph_explore`, callers and impact followed renders that never happen: in SigNoz, antd's `<Button>` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `<Typography>` and `<Button>` tags showed up as callers of the theme's style overrides. A package here is one that a `package.json` above the file lists and that no file in the repository answers to. A tag imported through a path alias like `components/Modal`, from a workspace package of the repository, or under a name no `package.json` lists still links as before. Re-index React and Vue projects after upgrading.
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
- Indexing and sync on large projects, especially C and C++ codebases with huge generated headers, now keep cached source text and preprocessor data within a fixed memory budget, so the final resolution pass no longer runs out of memory. (#1583)
- `codegraph_impact` on a heavily referenced symbol now stops at a safety limit and says the answer was truncated, instead of exhausting the MCP server's memory; narrow it with `file` or a smaller `depth`. (#1583)
- The MCP server now recycles its query workers after they sit idle, releasing the memory a burst of large queries left behind while keeping one worker warm for the next call. (#1583)
- Path search between two symbols no longer slows down sharply or grows its memory use on densely connected graphs. (#1583)

## [1.6.2] - 2026-10-03

Expand Down
74 changes: 64 additions & 10 deletions __tests__/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -650,6 +682,29 @@ describe('Traversal edge-completeness & limits (#1086–#1090)', () => {
const callees = tGraph(depthNodes, edges).getCallees('t', 2);
expect(callees.map((c) => c.node.id).sort()).toEqual(['a', 'b', 'c']);
});

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);
});
});

describe('findPath enqueue-once (#1359)', () => {
Expand Down Expand Up @@ -686,22 +741,21 @@ describe('findPath enqueue-once (#1359)', () => {
for (const b of layerB) edges.push({ source: b, target: 'end', kind: 'calls' });
seed(ids, edges);

// Count actual queue insertions, without timing thresholds or replacing SQLite.
// Count target lookups: a node is looked up only when it is first
// discovered and enqueued, so a repeat would mean a duplicate queue entry.
// No timing thresholds, and SQLite stays real.
const counts = new Map<string, number>();
const push = Array.prototype.push;
const queries = cg['queries'];
const getNodesByIds = queries.getNodesByIds;
let result: ReturnType<CodeGraph['findPath']>;
try {
Array.prototype.push = function (...items) {
for (const item of items) {
if (item && typeof item.nodeId === 'string' && Array.isArray(item.path)) {
counts.set(item.nodeId, (counts.get(item.nodeId) ?? 0) + 1);
}
}
return Reflect.apply(push, this, items);
queries.getNodesByIds = function (ids) {
for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1);
return getNodesByIds.call(this, ids);
};
result = cg.findPath('start', 'end', ['calls']);
} finally {
Array.prototype.push = push;
queries.getNodesByIds = getNodesByIds;
}
expect(result?.map((step) => step.node.id)).toEqual(['start', 'a0', 'b0', 'end']);
expect(result?.slice(1).every((step) => step.edge?.kind === 'calls')).toBe(true);
Expand Down
38 changes: 38 additions & 0 deletions __tests__/integration/lru-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>(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<string, string>(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<string, string>(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<string, number>(100);
for (let i = 0; i < 10_000; i++) {
Expand Down
93 changes: 91 additions & 2 deletions __tests__/query-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ class FakeWorker implements PoolWorker {
private errorCb?: (e: Error) => void;
private exitCb?: (code: number) => void;
alive = true;
terminated = false;
exitCode: number | undefined;
/** Ignore the pool's 'close', to exercise its terminate fallback. */
ignoresClose = false;
constructor(private behavior: (m: CallMsg) => Action, readyOk: boolean | null = true) {
setTimeout(() => { if (this.alive && readyOk !== null) this.emitMessage({ type: 'ready', ok: readyOk }); }, 0);
}
Expand All @@ -51,7 +55,14 @@ class FakeWorker implements PoolWorker {
if (this.alive) this.msgCb?.({ type: 'result', id, result });
}
postMessage(msg: unknown): void {
const m = msg as CallMsg;
const m = msg as CallMsg | { type: 'close' };
if (m?.type === 'close') {
// Like the real worker: close up and exit by itself, with code 0.
if (this.ignoresClose) return;
this.alive = false;
setTimeout(() => { this.exitCode = 0; this.exitCb?.(0); }, 0);
return;
}
if (!m || m.type !== 'call') return;
const action = this.behavior(m);
if ('crash' in action) {
Expand All @@ -63,7 +74,7 @@ class FakeWorker implements PoolWorker {
if ('wait' in action) { void action.wait.then((r) => this.reply(m.id, r)); return; }
setTimeout(() => this.reply(m.id, action.result), 0);
}
terminate(): Promise<number> { this.alive = false; return Promise.resolve(0); }
terminate(): Promise<number> { this.alive = false; this.terminated = true; return Promise.resolve(0); }
}

const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] });
Expand Down Expand Up @@ -126,6 +137,59 @@ describe('QueryPool', () => {
await pool.destroy();
});

it('recycles an idle burst back to one fresh worker', async () => {
let release!: () => void;
const gate = new Promise<void>((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);
// Each used worker exited by itself on 'close'; none was terminated.
expect(workers.slice(0, 4).every((w) => w.exitCode === 0 && !w.terminated)).toBe(true);
expect(pool.ready).toBe(true);
const again = await pool.run('codegraph_node', { symbol: 's' });
expect(again.isError).toBeFalsy();
await pool.destroy();
});

it('terminates a retired idle worker that does not exit by itself', async () => {
const workers: FakeWorker[] = [];
const pool = new QueryPool({
root: '/x', size: 1, idleShrinkMs: 20, retireTimeoutMs: 200,
createWorker: () => {
const worker = new FakeWorker(() => ({ result: ok('r') }));
worker.ignoresClose = workers.length === 0;
workers.push(worker);
return worker;
},
});

await pool.run('codegraph_search', {});
await vi.waitFor(() => expect(workers).toHaveLength(2)); // retired, replaced
expect(workers[0].terminated).toBe(false); // still has time to exit
await vi.waitFor(() => expect(workers[0].terminated).toBe(true));
expect(workers[1].terminated).toBe(false);
await pool.destroy();
});

it('recovers from a worker crash: retries the in-flight call and respawns', async () => {
let calls = 0;
const pool = new QueryPool({
Expand Down Expand Up @@ -465,6 +529,31 @@ describe('MCP query pool with real projects (#1465)', () => {
}
}, 30000);

it('retires an idle real worker that exits by itself with code 0', async () => {
const alpha = await indexProject('alpha', 'alphaSymbol');
const terminated: boolean[] = [];
const exits: Promise<number>[] = [];
pool = new QueryPool({
root: alpha, size: 1, idleShrinkMs: 50,
createWorker: () => {
const worker = new Worker(path.resolve(__dirname, '../dist/mcp/query-worker.js'), { workerData: { root: alpha } });
const i = terminated.push(false) - 1;
const terminate = worker.terminate.bind(worker);
worker.terminate = () => {
terminated[i] = true;
return terminate();
};
exits.push(new Promise((resolve) => worker.once('exit', resolve)));
return worker;
},
});
const result = await pool.run('codegraph_search', { query: 'alphaSymbol' });
expect(result.isError).toBeFalsy();
expect(await exits[0]).toBe(0);
expect(terminated[0]).toBe(false);
expect(exits).toHaveLength(2); // a fresh worker replaced it
}, 30000);

it('never terminates a real worker before it has started', async () => {
const alpha = await indexProject('alpha', 'alphaSymbol');
const startedWhenEnded: boolean[] = [];
Expand Down
26 changes: 20 additions & 6 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2192,8 +2192,8 @@ export class QueryBuilder {
* Get outgoing edges from a node. Preserve the source/kind index order
* (calls before imports/references), then break ties deterministically.
*/
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];

Expand All @@ -2208,6 +2208,10 @@ export class QueryBuilder {
}

sql += ' ORDER BY kind, target, line, col';
if (limit !== undefined) {
sql += ' LIMIT ?';
params.push(Math.max(0, Math.floor(limit)));
}
const rows = this.edgeKindStmt(sql).all(...params) as EdgeRow[];
return rows.map(rowToEdge);
}
Expand All @@ -2225,10 +2229,20 @@ export class QueryBuilder {
* displace actual calls in capped caller lists. Keep deterministic ties
* without changing the target/kind index's established kind precedence.
*/
getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] {
if (kinds && kinds.length > 0) {
const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')}) ORDER BY kind, source, line, col`;
const rows = this.edgeKindStmt(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);
}
sql += ' ORDER BY kind, source, line, col';
if (limit !== undefined) {
sql += ' LIMIT ?';
params.push(Math.max(0, Math.floor(limit)));
}
const rows = this.edgeKindStmt(sql).all(...params) as EdgeRow[];
return rows.map(rowToEdge);
}

Expand Down
Loading