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

- The background file watcher no longer exhausts your machine's file-descriptor budget. On macOS it previously kept **one open file handle per watched file**, so on a large project the running MCP server could pile up tens of thousands of handles and blow past the system-wide limit — at which point *unrelated* apps (your shell, editor, Docker, browser) started failing with "too many open files" until the codegraph process was killed. The watcher now uses a single recursive watch on macOS and Windows, and bounded per-directory watches on Linux, so its cost stays flat no matter how large the project is. (#644, #496, #555, #628, #579)
- Indexing a project with very symbol-dense files (tens of thousands of functions or methods in a single file) no longer runs out of memory. The step that links dynamic call relationships used to load every function and method into memory at once, which could exhaust the heap and abort indexing with "JavaScript heap out of memory" on large or generated codebases; it now streams them, so memory stays flat no matter how many symbols the project has. (#610)
- Indexing a very large repository no longer aborts during its first sync with a "too many SQL variables" error. (#540)
- Files under directories with non-ASCII names (for example CJK characters) are no longer silently skipped during indexing. (#541)
- The `.codegraph/` index folder no longer clutters `git status`: its generated ignore file now excludes everything in the folder except itself, so the database, `daemon.pid`, sockets, and logs stop showing up as untracked changes. (#492, #484)
- SAP HANA `.xsjs` / `.xsjslib` files are now indexed as JavaScript. (#556)
- TypeScript `.mts` and `.cts` module files are now indexed instead of being skipped. (#366)
- JavaScript modules that wrap their code in an anonymous function — AMD/RequireJS, NetSuite SuiteScript, IIFE bundles — now have their inner functions and calls indexed, instead of the file coming up nearly empty. (#528)
- Go methods declared on generic types (e.g. `func (s *Stack[T]) Push(...)`) are now correctly attached to their type, so callers, callees, and impact include them. (#583)
- Asking what a symbol impacts no longer drags in every unrelated sibling method of its class — impact now follows real dependencies instead of the structural "contains" relationship, keeping the result focused on what actually depends on the symbol. (#536)
- CodeGraph's MCP server now answers an agent's `resources/list` and `prompts/list` probes with an empty list instead of an error, clearing the `-32601` messages some clients (opencode, Codex) logged on connect. (#621)

## [0.9.9] - 2026-06-02

Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@

## Get Started

### 1. Install the CLI

**No Node.js required** — one command grabs the right build for your OS:

```bash
Expand All @@ -42,13 +44,22 @@ irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 |
Already have Node? Use npm instead (works on any version):

```bash
npx @colbymchenry/codegraph # zero-install, or:
npm i -g @colbymchenry/codegraph
```

<sub>CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro.</sub>
<sub>CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The installer puts `codegraph` on your PATH but **doesn't change your current shell** — open a new terminal before the next step so the command resolves.</sub>

### 2. Wire up your agent(s)

In a **new terminal**, run the installer to connect CodeGraph to the agents you use:

```bash
codegraph install
```

<sub>Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)</sub>

### Initialize Projects
### 3. Initialize each project

```bash
cd your-project
Expand Down
48 changes: 47 additions & 1 deletion __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { extractFromSource, scanDirectory } from '../src/extraction';
import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars } from '../src/extraction/grammars';
import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars';
import { normalizePath } from '../src/utils';

beforeAll(async () => {
Expand Down Expand Up @@ -4387,3 +4387,49 @@ void helperFunction(int count) {
expect(getSupportedLanguages()).toContain('objc');
});
});

describe('Regression: issue-specific extraction fixes', () => {
it('indexes inner functions of an anonymous AMD/CommonJS module wrapper (#528)', () => {
const code = `
define(['dep'], function (dep) {
function innerHelper(x) { return x + 1; }
function compute(y) { return innerHelper(y); }
return { compute: compute };
});
`;
const result = extractFromSource('amd-module.js', code);
const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(fns).toContain('innerHelper');
expect(fns).toContain('compute');
});

it('attaches Go methods on generic receivers to their type (#583)', () => {
const code = `
package main

type Stack[T any] struct { items []T }

func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s Stack[T]) Len() int { return len(s.items) }
`;
const result = extractFromSource('stack.go', code);
const methods = result.nodes.filter((n) => n.kind === 'method');
expect(methods.find((m) => m.name === 'Push')?.qualifiedName).toBe('Stack::Push');
expect(methods.find((m) => m.name === 'Len')?.qualifiedName).toBe('Stack::Len');
});

it('indexes new module extensions: .mts/.cts (TS) and .xsjs/.xsjslib (JS) (#366, #556)', () => {
expect(isSourceFile('mod.mts')).toBe(true);
expect(isSourceFile('mod.cts')).toBe(true);
expect(isSourceFile('service.xsjs')).toBe(true);
expect(isSourceFile('lib.xsjslib')).toBe(true);
expect(detectLanguage('mod.mts')).toBe('typescript');
expect(detectLanguage('service.xsjs')).toBe('javascript');

// End-to-end: a .mts file is parsed as TS, a .xsjs file as JS.
const ts = extractFromSource('mod.mts', 'export function hello(): number { return 1; }');
expect(ts.nodes.find((n) => n.name === 'hello' && n.kind === 'function')).toBeDefined();
const js = extractFromSource('service.xsjs', 'function handleRequest() { return 1; }');
expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined();
});
});
5 changes: 4 additions & 1 deletion __tests__/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ describe('CodeGraph Foundation', () => {
expect(fs.existsSync(gitignorePath)).toBe(true);

const content = fs.readFileSync(gitignorePath, 'utf-8');
expect(content).toContain('*.db');
// Ignore everything in .codegraph/ except this file itself, so transient
// files (db, daemon.pid, sockets, logs) never show up in git. (#492, #484)
expect(content).toContain('*');
expect(content).toContain('!.gitignore');

cg.close();
});
Expand Down
13 changes: 13 additions & 0 deletions __tests__/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,19 @@ export { main };
expect(impact.nodes.size).toBeGreaterThan(0);
expect(impact.nodes.has(formatValue.id)).toBe(true);
});

it('does not drag in sibling members via the structural contains edge (#536)', () => {
const getName = cg.getNodesByKind('method').find((n) => n.name === 'getName');
const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass');
expect(getName).toBeDefined();
expect(derived).toBeDefined();

const impact = cg.getImpactRadius(getName!.id, 3);
// The containing class must NOT be pulled into impact just because it
// *contains* getName — climbing that contains edge would re-expand every
// sibling method and explode impact for a leaf symbol. (#536)
expect(impact.nodes.has(derived!.id)).toBe(false);
});
});

describe('findPath()', () => {
Expand Down
26 changes: 26 additions & 0 deletions __tests__/mcp-initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,30 @@ describe('MCP initialize handshake (issue #172)', () => {
expect(json.id).toBe(0);
expect(json.result.serverInfo.name).toBe('codegraph');
}, 20000);

it('answers resources/list and prompts/list with empty lists, not -32601 (issue #621)', async () => {
child = spawnServer(tempDir);
const events = tagStreams(child);
sendInitialize(child, tempDir);
await waitFor(events, (e) => e.stream === 'stdout', 5000); // initialize reply

child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'resources/list', params: {} }) + '\n');
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'prompts/list', params: {} }) + '\n');

const replyFor = async (id: number) => {
const ev = await waitFor(events, (e) => {
if (e.stream !== 'stdout') return false;
try { return JSON.parse(e.text).id === id; } catch { return false; }
}, 5000);
return JSON.parse(ev.text);
};

const resources = await replyFor(1);
expect(resources.error).toBeUndefined();
expect(resources.result.resources).toEqual([]);

const prompts = await replyFor(2);
expect(prompts.error).toBeUndefined();
expect(prompts.result.prompts).toEqual([]);
}, 15000);
});
17 changes: 13 additions & 4 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1588,10 +1588,19 @@ export class QueryBuilder {
getUnresolvedReferencesByFiles(filePaths: string[]): UnresolvedReference[] {
if (filePaths.length === 0) return [];

const placeholders = filePaths.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT * FROM unresolved_refs WHERE file_path IN (${placeholders})`)
.all(...filePaths) as UnresolvedRefRow[];
// Chunk under SQLite's parameter limit: the first sync of a very large repo
// passes every changed file here, which an unbounded `IN (...)` would bind
// as one parameter each — exceeding MAX_VARIABLE_NUMBER and aborting with
// "too many SQL variables". (#540)
const rows: UnresolvedRefRow[] = [];
for (let i = 0; i < filePaths.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = filePaths.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const chunkRows = this.db
.prepare(`SELECT * FROM unresolved_refs WHERE file_path IN (${placeholders})`)
.all(...chunk) as UnresolvedRefRow[];
rows.push(...chunkRows);
}

return rows.map((row) => ({
fromNodeId: row.from_node_id,
Expand Down
23 changes: 6 additions & 17 deletions src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,22 +83,11 @@ export function createDirectory(projectRoot: string): void {
// Create .gitignore inside .codegraph (if it doesn't exist)
const gitignorePath = path.join(codegraphDir, '.gitignore');
if (!fs.existsSync(gitignorePath)) {
const gitignoreContent = `# CodeGraph data files
# These are local to each machine and should not be committed

# Database
*.db
*.db-wal
*.db-shm

# Cache
cache/

# Logs
*.log

# Hook markers
.dirty
const gitignoreContent = `# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
`;

fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8');
Expand Down Expand Up @@ -245,7 +234,7 @@ export function validateDirectory(projectRoot: string): {
const gitignorePath = path.join(codegraphDir, '.gitignore');
if (!fs.existsSync(gitignorePath)) {
try {
const gitignoreContent = `# CodeGraph data files\n# These are local to each machine and should not be committed\n\n# Database\n*.db\n*.db-wal\n*.db-shm\n\n# Cache\ncache/\n\n# Logs\n*.log\n\n# Hook markers\n.dirty\n`;
const gitignoreContent = `# CodeGraph data fileslocal to each machine, not for committing.\n# Ignore everything in .codegraph/ except this file itself, so transient\n# files (the database, daemon.pid, sockets, logs) never show up in git.\n*\n!.gitignore\n`;
fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8');
} catch {
// Non-fatal: warn but don't block
Expand Down
6 changes: 6 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,15 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
export const EXTENSION_MAP: Record<string, Language> = {
'.ts': 'typescript',
'.tsx': 'tsx',
// ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366)
'.mts': 'typescript',
'.cts': 'typescript',
'.js': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
// SAP HANA XS Classic server-side JavaScript. (#556)
'.xsjs': 'javascript',
'.xsjslib': 'javascript',
'.jsx': 'jsx',
'.py': 'python',
'.pyw': 'python',
Expand Down
28 changes: 14 additions & 14 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,32 +198,32 @@ function collectGitFiles(repoDir: string, prefix: string, files: Set<string>): v
// Without this, monorepos using submodules index 0 files. (See issue #147.)
// Note: --recurse-submodules only supports -c/--cached and --stage modes — it
// can't be combined with -o, so untracked files are gathered separately below.
const tracked = execFileSync('git', ['ls-files', '-c', '--recurse-submodules'], gitOpts);
for (const line of tracked.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
files.add(normalizePath(prefix + trimmed));
}
// -z gives NUL-separated, unquoted output so non-ASCII (e.g. CJK) paths
// survive verbatim. Without it git octal-escapes and double-quotes such paths
// (the core.quotepath default), and the quoted form never matches a real file
// on disk → those files are silently dropped from the index. (#541)
const tracked = execFileSync('git', ['ls-files', '-z', '-c', '--recurse-submodules'], gitOpts);
for (const rel of tracked.split('\0')) {
if (rel) files.add(normalizePath(prefix + rel));
}

// Untracked files (submodules manage their own untracked state). Embedded git
// repos surface here as a single "subdir/" entry that git refuses to descend
// into — recurse into those as their own repos so their source gets indexed.
const untracked = execFileSync('git', ['ls-files', '-o', '--exclude-standard'], gitOpts);
for (const line of untracked.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.endsWith('/')) {
const untracked = execFileSync('git', ['ls-files', '-z', '-o', '--exclude-standard'], gitOpts);
for (const rel of untracked.split('\0')) {
if (!rel) continue;
if (rel.endsWith('/')) {
// git only emits a trailing-slash directory entry for an embedded repo.
// Guard with a .git check anyway, and skip anything else exactly as git
// itself skips it (we never descend into a non-repo opaque dir).
const childDir = path.join(repoDir, trimmed);
const childDir = path.join(repoDir, rel);
if (fs.existsSync(path.join(childDir, '.git'))) {
collectGitFiles(childDir, prefix + trimmed, files);
collectGitFiles(childDir, prefix + rel, files);
}
continue;
}
files.add(normalizePath(prefix + trimmed));
files.add(normalizePath(prefix + rel));
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/extraction/languages/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ export const goExtractor: LanguageExtractor = {
if (!receiver) return undefined;
// Find the type identifier inside the receiver
const text = getNodeText(receiver, source);
// Extract type name from patterns like "(sl *Type)", "(sl Type)", "(*Type)", "(Type)"
const match = text.match(/\*?\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
// Extract type name from "(sl *Type)", "(sl Type)", "(*Type)", "(Type)" and
// generic receivers "(s *Stack[T])". Anchor on the opening "(" and skip an
// optional receiver var name; the old `name)`-anchored pattern never matched
// the `[T])` suffix, so generic-type methods were orphaned from their type
// (no struct→method `contains` edge). (#583)
const match = text.match(/\(\s*(?:[A-Za-z_]\w*\s+)?\*?\s*([A-Za-z_]\w*)/);
return match?.[1];
},
};
14 changes: 13 additions & 1 deletion src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,19 @@ export class TreeSitterExtractor {
}
}
}
if (name === '<anonymous>') return; // Skip anonymous functions
if (name === '<anonymous>') {
// Don't emit a node for the anonymous wrapper itself, but still visit its
// body: AMD/RequireJS and CommonJS module wrappers (`define([], function(){…})`,
// `(function(){…})()`) hold named inner functions and calls that would
// otherwise be lost — the dispatcher set skipChildren, so nothing else
// descends into this subtree. (#528)
const body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
?? getChildByField(node, this.extractor.bodyField);
if (body) {
this.visitFunctionBody(body, '');
}
return;
}

// Check for misparse artifacts (e.g. C++ macros causing "namespace detail" functions)
// Skip the node but still visit the body for calls and structural nodes
Expand Down
7 changes: 5 additions & 2 deletions src/graph/traversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,8 +521,11 @@ export class GraphTraverser {
}
}

// Get all incoming edges (things that depend on this node)
const incomingEdges = this.queries.getIncomingEdges(nodeId);
// Get all incoming edges (things that depend on this node). Exclude
// `contains`: a container "contains" its members but does not *depend* on
// them, so following it upward would climb to the parent class and then
// re-expand every sibling member — exploding impact for a leaf symbol. (#536)
const incomingEdges = this.queries.getIncomingEdges(nodeId).filter((e) => e.kind !== 'contains');
if (incomingEdges.length === 0) return;
const sources = this.queries.getNodesByIds(incomingEdges.map((e) => e.source));

Expand Down
8 changes: 8 additions & 0 deletions src/mcp/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
routeToDaemon(line); // prime the daemon so it resolves the project (its reply is suppressed below)
} else if (msg.method === 'tools/list') {
writeClient({ jsonrpc: '2.0', id: msg.id, result: { tools: getStaticTools() } });
} else if (msg.method === 'resources/list') {
// No resources exposed — answer the probe locally so it never reaches
// the daemon as an unhandled method and logs `-32601`. (#621)
writeClient({ jsonrpc: '2.0', id: msg.id, result: { resources: [] } });
} else if (msg.method === 'resources/templates/list') {
writeClient({ jsonrpc: '2.0', id: msg.id, result: { resourceTemplates: [] } });
} else if (msg.method === 'prompts/list') {
writeClient({ jsonrpc: '2.0', id: msg.id, result: { prompts: [] } });
} else {
routeToDaemon(line);
}
Expand Down
13 changes: 13 additions & 0 deletions src/mcp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,19 @@ export class MCPSession {
case 'ping':
if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, {});
break;
case 'resources/list':
// We expose no MCP resources, but some clients (opencode, Codex) probe
// for them on connect; reply with an empty list instead of a
// MethodNotFound error that surfaces as a scary `-32601` log line. (#621)
if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, { resources: [] });
break;
case 'resources/templates/list':
if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, { resourceTemplates: [] });
break;
case 'prompts/list':
// Likewise — no prompts exposed, but answer the probe cleanly. (#621)
if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, { prompts: [] });
break;
default:
if (isRequest) {
this.transport.sendError(
Expand Down