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

#### Symbols, tests and the viewer

- 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.

- **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.

- **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does.
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 @@ -9704,6 +9704,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([]);
});
});
57 changes: 43 additions & 14 deletions __tests__/wal-deferral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -193,6 +193,19 @@ function writeFixtureProject(): void {
}
}

async function seedPendingRefs(cg: CodeGraph): Promise<void> {
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 () => {
Expand Down Expand Up @@ -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)', () => {
Expand All @@ -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<void> {
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);
Expand Down
Loading