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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Resolution no longer reads oversized dependency archives such as HarmonyOS `.har` packages as source text, preventing a single package target from exhausting the JavaScript heap during indexing or sync.
- Dynamic-dispatch analysis no longer repeatedly copies every source prefix while scanning match-dense files, avoiding quadratic work and excessive peak memory during the final resolution pass.
- Naming a file by its path in a `codegraph_explore` query now works reliably: the path is resolved against the index and that file is guaranteed a place at the top of the answer. Previously the path was broken into fragments — bracketed route segments like SvelteKit's `[id]` made this worst — and pieces like `page` or `runs` matched every sibling file, so the file you actually named could be crowded out of the answer entirely. A path that doesn't match any indexed file is now called out instead of silently ignored.
- Plainly-worded `codegraph_explore` questions now find camelCase code: a query like "auto-scroll to bottom" can reach a function named `scrollFeedToBottom`, because query words are matched against the words inside identifiers, not just whole names.
- Variables and constants now count when `codegraph_explore` picks its starting symbols, so state held in plain variables — `$state`-style variables in Svelte, for example — no longer gets overlooked.
Expand Down
20 changes: 18 additions & 2 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -9469,6 +9469,22 @@ import foo.cfm;
</cfcomponent>
`;

it('releases the tag parser tree after extraction', () => {
const parser = getParser('cfml');
expect(parser).toBeDefined();
const sample = parser!.parse('<cfcomponent></cfcomponent>');
expect(sample).toBeDefined();
const treePrototype = Object.getPrototypeOf(sample!);
sample!.delete();
const deleteSpy = vi.spyOn(treePrototype, 'delete');
try {
extractFromSource('TagStyle.cfc', '<cfcomponent></cfcomponent>');
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');
Expand Down
55 changes: 55 additions & 0 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 @@ -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);
});
});
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
5 changes: 4 additions & 1 deletion __tests__/object-registry-synthesizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
});
Expand Down
31 changes: 31 additions & 0 deletions __tests__/query-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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);
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({
Expand Down
42 changes: 42 additions & 0 deletions __tests__/resolution-file-read.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 16 additions & 1 deletion __tests__/synthesis-tail-scaling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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([]);
});
});
36 changes: 36 additions & 0 deletions __tests__/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (event: string, filename: string | Buffer | null) => void>();
const closeCounts = new Map<string, number>();
__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[]) => {
Expand Down
26 changes: 20 additions & 6 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1741,8 +1741,8 @@ export class QueryBuilder {
/**
* Get outgoing edges from a node
*/
getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string): Edge[] {
if ((kinds && kinds.length > 0) || provenance) {
getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string, limit?: number): Edge[] {
if ((kinds && kinds.length > 0) || provenance || limit !== undefined) {
let sql = 'SELECT * FROM edges WHERE source = ?';
const params: (string | number)[] = [sourceId];

Expand All @@ -1756,6 +1756,11 @@ export class QueryBuilder {
params.push(provenance);
}

if (limit !== undefined) {
sql += ' LIMIT ?';
params.push(Math.max(0, Math.floor(limit)));
}

const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
return rows.map(rowToEdge);
}
Expand All @@ -1770,10 +1775,19 @@ export class QueryBuilder {
/**
* Get incoming edges to a node
*/
getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] {
if (kinds && kinds.length > 0) {
const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`;
const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[];
getIncomingEdges(targetId: string, kinds?: EdgeKind[], limit?: number): Edge[] {
if ((kinds && kinds.length > 0) || limit !== undefined) {
let sql = 'SELECT * FROM edges WHERE target = ?';
const params: (string | number)[] = [targetId];
if (kinds && kinds.length > 0) {
sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
params.push(...kinds);
}
if (limit !== undefined) {
sql += ' LIMIT ?';
params.push(Math.max(0, Math.floor(limit)));
}
const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
return rows.map(rowToEdge);
}

Expand Down
Loading