Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,13 @@
# Union misfiles fork entries once upstream has cut a release, so after every
# upstream merge run `node scripts/changelog-reconcile.mjs` to rebuild the file.
CHANGELOG.md merge=union

# Third-party tree-sitter grammars that the native kernel compiles from source
# (codegraph-kernel/build.rs). Keep all of them out of GitHub's language
# statistics, and collapse only the generated parser.c files in pull-request
# diffs. The hand-written scanner.c files are where C gets patched or added,
# so they stay expanded for review.
# The /** is needed: an attributes pattern that names a directory does not
# reach the files inside it.
codegraph-kernel/grammars/** linguist-vendored
codegraph-kernel/grammars/**/parser.c linguist-generated
6 changes: 6 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1078,12 +1078,15 @@ See [Get Started](#get-started) for the one-line install commands.
## Supported Agents

The interactive installer auto-detects and configures each of these — wiring up
the MCP server (which delivers its own usage guidance, so no instructions file
is written):
the MCP server, which delivers its own usage guidance. For Claude Code, Codex
CLI, Gemini CLI and opencode it also writes a short marker-fenced CodeGraph
section into the agent's instructions file (`CLAUDE.md` / `AGENTS.md` /
`GEMINI.md`), so subagents and non-MCP agents learn the `codegraph explore`
command:

- **Claude Code**
- **Cursor**
- **Codex CLI**
- **Codex CLI** — the Codex IDE extension reads the same config: a global install writes `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`), and a local install writes `.codex/config.toml`, which Codex uses only in a project you've marked as trusted
- **opencode** — MCP entry is OpenCode 2's `mcp.servers.codegraph` with `codemode: false` (keeps `codegraph_explore` on the native tool list; `codegraph install` migrates the older `mcp.codegraph` shape)
- **Hermes Agent**
- **Gemini CLI**
Expand Down Expand Up @@ -1185,7 +1188,7 @@ Framework routing is validated the same way, on a canonical app per framework: E

**Two `codegraph serve --mcp` on one project fight over the index / auto-sync stops** — CodeGraph allows one live MCP *writer* per project (the shared background daemon, or a single direct-mode process). Extra clients should proxy to that daemon. If you set `CODEGRAPH_NO_DAEMON=1`, run only one `serve --mcp` for that project; a second instance exits with a clear writer-lock error (see `writer.pid` under `.codegraph/`; its persistent `writer.pid.mutation.lock` coordinates ownership changes and needs no stale-lock deletion). Prefer leaving the daemon enabled so multiple MCP hosts share one watcher.

**MCP tool calls fail with `Transport closed` while `codegraph status`/`sync` are healthy** — almost always WSL2 with the project on a Windows drive (a `/mnt/c` or `/mnt/d` path), where the local socket CodeGraph uses to share one background server across sessions is unreliable. CodeGraph now falls back to serving the session in-process instead of dropping the connection, and keeps trying to reach the shared server in the background: first after 5 seconds, backing off to every 5 minutes. `CODEGRAPH_DAEMON_RETRY_MS` and `CODEGRAPH_DAEMON_RETRY_MAX_MS` set those two delays, and `CODEGRAPH_DAEMON_RETRY_MS=0` stops retrying. If you still hit it, set `CODEGRAPH_NO_DAEMON=1` in your MCP server's environment to skip the shared server entirely (each session runs in its own process). Moving the project onto the Linux-native filesystem (e.g. under `~/` instead of `/mnt/`) restores the shared server.
**MCP tool calls fail with `Transport closed` while `codegraph status`/`sync` are healthy** — almost always WSL2 with the project on a Windows drive (a `/mnt/c` or `/mnt/d` path), where the local socket CodeGraph uses to share one background server across sessions is unreliable. CodeGraph works around this itself now: when the project's filesystem can't host that socket, the shared server puts it in the system temp directory instead, and when the shared server still can't be reached, or drops, the session is served in-process instead of losing the connection, and keeps trying to reach the shared server in the background: first after 5 seconds, backing off to every 5 minutes. `CODEGRAPH_DAEMON_RETRY_MS` and `CODEGRAPH_DAEMON_RETRY_MAX_MS` set those two delays, and `CODEGRAPH_DAEMON_RETRY_MS=0` stops retrying. If you still hit it, set `CODEGRAPH_NO_DAEMON=1` in your MCP server's environment to skip the shared server entirely (each session runs in its own process). Moving the project onto the Linux-native filesystem (e.g. under `~/` instead of `/mnt/`) restores the shared server.

**Missing symbols** — The MCP server auto-syncs on save (wait a couple seconds). Run `codegraph sync` manually if needed. Check that the file's language is supported and isn't inside a `.gitignore`d or default-excluded directory (e.g. `node_modules`, `dist`).

Expand Down
97 changes: 97 additions & 0 deletions __tests__/add-lang-verify-extraction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* scripts/add-lang/verify-extraction.mjs reads `codegraph status --json` from
* the codegraph that built an index. It used to call `codegraph` by bare name,
* which never works on Windows: npm installs codegraph there as a .cmd shim,
* and Node can neither find a .cmd by bare name nor run one without a shell.
* /add-lang's bench.sh then skipped its A/B as if extraction had failed.
*
* A stand-in for codegraph prints a healthy status and records the arguments
* it received, so each test can check what reached it.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

const SCRIPT = path.resolve('scripts/add-lang/verify-extraction.mjs');
const roots: string[] = [];

const FAKE_CODEGRAPH = `
const fs = require('fs');
fs.writeFileSync(process.env.CG_TEST_ARGV, JSON.stringify(process.argv.slice(2)));
console.log(JSON.stringify({
initialized: true, backend: 'node-sqlite', fileCount: 2, nodeCount: 9, edgeCount: 7,
languages: ['lua'], nodesByKind: { file: 2, function: 5, variable: 2 },
}));
`;

function kit() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-verify-extraction-'));
roots.push(root);
const fake = path.join(root, 'fake-codegraph.cjs');
fs.writeFileSync(fake, FAKE_CODEGRAPH);
// A folder name like npm's prefix under "Program Files (x86)".
const bin = path.join(root, 'bin (x86)');
fs.mkdirSync(bin);
return { root, fake, bin, argv: path.join(root, 'argv.json') };
}

// The script's environment, with PATH limited to `dirs` plus what node needs,
// so no codegraph installed on the machine running the tests can answer.
function envWith(k: ReturnType<typeof kit>, dirs: string[], extra: NodeJS.ProcessEnv = {}) {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.toUpperCase() !== 'PATH' && key !== 'CODEGRAPH_BIN') env[key] = value;
}
const system = process.platform === 'win32' ? [path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')] : ['/usr/bin', '/bin'];
env.PATH = [...dirs, path.dirname(process.execPath), ...system].join(path.delimiter);
env.CG_TEST_ARGV = k.argv;
return { ...env, ...extra };
}

function verify(k: ReturnType<typeof kit>, repo: string, env: NodeJS.ProcessEnv) {
const r = spawnSync(process.execPath, [SCRIPT, repo, 'lua'], { encoding: 'utf8', timeout: 60_000, env });
const received = fs.existsSync(k.argv) ? JSON.parse(fs.readFileSync(k.argv, 'utf8')) : null;
return { status: r.status, output: `${r.stdout}${r.stderr}`, received };
}

afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});

describe('add-lang verify-extraction: which codegraph it asks', () => {
it('reads the status through CODEGRAPH_BIN, running a .js path with node', () => {
const k = kit();
const repo = path.join(k.root, 'some repo');
const r = verify(k, repo, envWith(k, [], { CODEGRAPH_BIN: k.fake }));
expect(r.output).toContain('RESULT: PASS');
expect(r.status).toBe(0);
expect(r.received).toEqual(['status', repo, '--json']);
});

it.runIf(process.platform === 'win32')('finds the npm .cmd shim on PATH and passes an awkward repo path through cmd.exe intact', () => {
const k = kit();
// The shape of npm's shim: cmd.exe runs it and %* hands the arguments on.
fs.writeFileSync(path.join(k.bin, 'codegraph.cmd'), '@ECHO off\r\nSETLOCAL\r\nSET "_prog=node"\r\n"%_prog%" "%~dp0..\\fake-codegraph.cjs" %*\r\n');
// Spaces, & and parentheses, a defined %NAME%, a lone %, and a trailing
// backslash that must not escape the closing quote.
const repo = path.join(k.root, 'repo & co (x86) %CG_TEST_PCT% 50%') + '\\';
const r = verify(k, repo, envWith(k, [k.bin], { CG_TEST_PCT: 'expanded' }));
expect(r.output).toContain('RESULT: PASS');
expect(r.status).toBe(0);
expect(r.received).toEqual(['status', repo, '--json']);
});

it.runIf(process.platform !== 'win32')('runs the codegraph on PATH directly, as before', () => {
const k = kit();
const shim = path.join(k.bin, 'codegraph');
fs.writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "${k.fake}" "$@"\n`);
fs.chmodSync(shim, 0o755);
const repo = path.join(k.root, 'some repo');
const r = verify(k, repo, envWith(k, [k.bin]));
expect(r.output).toContain('RESULT: PASS');
expect(r.status).toBe(0);
expect(r.received).toEqual(['status', repo, '--json']);
});
});
203 changes: 203 additions & 0 deletions __tests__/callers-call-sites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* `codegraph callers` lists every place a caller calls the symbol, and a
* symbol that calls itself among its callers (#2909).
*
* Callers kept one row per caller (#1086), with the caller's first edge only,
* and left the symbol itself out, so a method called on two lines of `build`
* showed one, and a recursive call none at all, though the index stores them
* and each is a line to edit when the symbol changes. The rows stay one per
* caller; the sites go inside the row: `src/report.ts:9, 10`. The CLI, the
* codegraph_callers / codegraph_callees tools and codegraph_node's trail read
* them from one place, `getCallSites`, while `getCallers` / `getCallees`, which
* ranking and the flow and impact walks use, read as they did.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { ToolHandler } from '../src/mcp/tools';
import type { Node } from '../src/types';

const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
const BUSY = 30;

let root = '';
let cg: CodeGraph;
let handler: ToolHandler;

const files: Record<string, string> = {
// The issue's example, with the recursion written `this.walk()`.
'src/report.ts': [
'export class Logger {',
' log(message: string): void {}',
'}',
'',
'export class Report {',
' constructor(private readonly logger: Logger) {}',
'',
' build(): void {',
" this.logger.log('start');",
" this.logger.log('end');",
' }',
'}',
'',
'export class TreeNode {',
' children: TreeNode[] = [];',
'',
' walk(): void {',
' this.walk();',
' }',
'}',
'',
].join('\n'),
'src/once.ts': [
"import { Logger } from './report';",
'',
'export function once(logger: Logger): void {',
" logger.log('only');",
'}',
'',
].join('\n'),
'src/math.ts': [
'export function factorial(n: number): number {',
' return n <= 1 ? 1 : n * factorial(n - 1);',
'}',
'',
'export function tally(): number {',
' return factorial(3) + factorial(4);',
'}',
'',
].join('\n'),
// A caller with more call sites than any list shows.
'src/busy.ts': [
"import { Logger } from './report';",
'',
'export function busy(logger: Logger): void {',
...Array.from({ length: BUSY }, (_, i) => ` logger.log('${i}');`),
'}',
'',
].join('\n'),
};

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-call-sites-'));
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
handler = new ToolHandler(cg);
});

afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

function named(name: string, file: string): Node {
const node = cg.getNodesInFile(file).find((n) => n.name === name);
expect(node, `${file}:${name}`).toBeDefined();
return node!;
}

function cli(...args: string[]): string {
const result = spawnSync(process.execPath, [BIN, ...args, '-p', root], {
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', NO_COLOR: '1', DO_NOT_TRACK: '1', CODEGRAPH_TELEMETRY: '0' },
timeout: 60_000,
});
expect(result.status, result.stderr).toBe(0);
return result.stdout;
}

async function tool(name: string, args: Record<string, unknown>): Promise<string> {
const res = await handler.execute(name, args);
return res.content?.[0]?.text ?? '';
}

describe('call sites of a symbol (#2909)', () => {
it('keeps one row per caller with every site in it', () => {
const rows = cg.getCallSites(named('log', 'src/report.ts').id, 'callers');
const build = rows.filter((row) => row.node.name === 'build');
expect(build).toHaveLength(1);
expect(build[0]!.edges.map((e) => e.line)).toEqual([9, 10]);
expect(build[0]!.recursive).toBe(false);
expect(rows.find((row) => row.node.name === 'once')!.edges.map((e) => e.line)).toEqual([4]);
});

it('lists a symbol that calls itself as its own caller and callee', () => {
for (const [name, file, line] of [['walk', 'src/report.ts', 18], ['factorial', 'src/math.ts', 2]] as const) {
const node = named(name, file);
for (const direction of ['callers', 'callees'] as const) {
const self = cg.getCallSites(node.id, direction).filter((row) => row.node.id === node.id);
expect(self, `${name} ${direction}`).toHaveLength(1);
expect(self[0]!.recursive).toBe(true);
expect(self[0]!.edges.map((e) => e.line)).toEqual([line]);
}
}
});

it('leaves getCallers and getCallees as they were: the first edge, never the symbol itself', () => {
for (const node of cg.getNodesByKind('method').concat(cg.getNodesByKind('function'))) {
for (const direction of ['callers', 'callees'] as const) {
const before = (direction === 'callers' ? cg.getCallers(node.id) : cg.getCallees(node.id))
.map((c) => [c.node.id, c.edge.line, c.edge.kind]);
const now = cg.getCallSites(node.id, direction)
.filter((row) => !row.recursive)
.map((row) => [row.node.id, row.edges[0]!.line, row.edges[0]!.kind]);
expect(now, `${node.name} ${direction}`).toEqual(before);
}
}
});
});

describe('codegraph callers / callees', () => {
it('prints a caller at the lines it calls from, and a recursive one as calling itself', () => {
const text = cli('callers', 'Logger.log');
expect(text).toMatch(/method\s+build\n\s+src\/report\.ts:9, 10\n/);
expect(text).toMatch(/function\s+once\n\s+src\/once\.ts:4\n/);
expect(text.match(/\bbuild\b/g)).toHaveLength(1);
const walk = cli('callers', 'TreeNode.walk');
expect(walk).toMatch(/method\s+walk \(calls itself\)\n\s+src\/report\.ts:18\n/);
// Callees keep the callee's own line: where a call leads.
expect(cli('callees', 'tally')).toMatch(/function\s+factorial\n\s+src\/math\.ts:1\n/);
});

it('caps one caller at ten sites in text, and lists them all in JSON', () => {
expect(cli('callers', 'Logger.log')).toMatch(/function\s+busy\n\s+src\/busy\.ts:4, 5, 6, 7, 8, 9, 10, 11, 12, 13, \+20 more\n/);
const out = JSON.parse(cli('callers', 'Logger.log', '--json'));
const busy = out.definitions[0].callers.find((row: { name: string }) => row.name === 'busy');
expect(busy.lines).toEqual(Array.from({ length: BUSY }, (_, i) => 4 + i));
expect(busy.recursive).toBe(false);
const build = out.callers.find((row: { name: string }) => row.name === 'build');
expect(build).toMatchObject({ lines: [9, 10], recursive: false });
const buildId = out.definitions[0].callers.find((row: { name: string }) => row.name === 'build').id;
expect(out.definitions[0].edges.filter((e: { source: string }) => e.source === buildId).map((e: { line: number }) => e.line))
.toEqual([9, 10]);
const walk = JSON.parse(cli('callers', 'TreeNode.walk', '--json'));
expect(walk.callers).toEqual([expect.objectContaining({ name: 'walk', lines: [18], recursive: true })]);
});
});

describe('codegraph_callers / codegraph_callees and the codegraph_node trail', () => {
it('lists every site of a caller, capped, and a recursive call', async () => {
const callers = await tool('codegraph_callers', { symbol: 'Logger.log' });
expect(callers).toContain('- build (method) - src/report.ts:9, 10');
expect(callers).toContain('- once (function) - src/once.ts:4');
expect(callers).toContain('- busy (function) - src/busy.ts:4, 5, 6, 7, 8, 9, 10, 11, 12, 13, +20 more');
expect(await tool('codegraph_callers', { symbol: 'factorial' })).toContain('- factorial (function) - src/math.ts:2 — calls itself');
expect(await tool('codegraph_callees', { symbol: 'factorial' })).toContain('- factorial (function) - src/math.ts:1 — calls itself');
});

it('shows callers in the trail at their sites, three at most, and the recursion once', async () => {
const log = await tool('codegraph_node', { symbol: 'Logger.log' });
expect(log).toMatch(/\*\*Called by ←\*\* .*build \(src\/report\.ts:9, 10\)/);
expect(log).toContain('busy (src/busy.ts:4, 5, 6, +27 more)');
const factorial = await tool('codegraph_node', { symbol: 'factorial' });
expect(factorial).toMatch(/\*\*Called by ←\*\* .*factorial \(src\/math\.ts:2, calls itself\)/);
expect(factorial).toContain('tally (src/math.ts:6)');
expect(factorial).not.toMatch(/\*\*Calls →\*\*.*factorial/);
});
});
Loading
Loading