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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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 @@ -9517,6 +9517,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
188 changes: 188 additions & 0 deletions __tests__/mcp-subproject-adoption.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, any>> {
const messages: Array<Record<string, any>> = [];
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<Record<string, any>>,
predicate: (m: Record<string, any>) => boolean,
timeoutMs: number,
): Promise<Record<string, any>> {
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/<name> with one source file and an initialized .codegraph/. */
async function makeIndexedChild(ws: string, name: string): Promise<string> {
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<Record<string, any>>,
): Promise<{ initResult: Record<string, any>; 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);
});
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
Loading