Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
776a556
Add Godot language indexing support
KirisamaMarisa May 24, 2026
57b7432
Improve GDScript annotation extraction
KirisamaMarisa May 24, 2026
8fc3df8
Improve Godot scene and script graph extraction
KirisamaMarisa May 24, 2026
16937ba
Improve Godot reference and resource extraction
KirisamaMarisa May 24, 2026
5439eeb
Normalize Godot resource paths in symbol tools
KirisamaMarisa May 24, 2026
bd23e4c
Resolve Godot node path references
KirisamaMarisa May 24, 2026
36a2a79
Enhance Godot signal call extraction
KirisamaMarisa May 24, 2026
1675cbc
Extract Godot scene instance resource references
KirisamaMarisa May 24, 2026
a858dad
Add Godot resource alias references
KirisamaMarisa May 24, 2026
fa795ce
Aggregate Godot scene instance callers
KirisamaMarisa May 24, 2026
e048700
Resolve GDScript constant node paths
KirisamaMarisa May 24, 2026
d600b59
Extract GDScript dynamic node names
KirisamaMarisa May 24, 2026
6d0996b
Support GDScript formatted node names
KirisamaMarisa May 24, 2026
24b821e
Resolve GDScript node path aliases
KirisamaMarisa May 24, 2026
4d9b53d
Index GDScript node name constants
KirisamaMarisa May 24, 2026
82d49ba
Extract GDScript node lookup helpers
KirisamaMarisa May 24, 2026
0f9bc0e
Resolve GDScript node lookup wrappers
KirisamaMarisa May 24, 2026
f459ebc
Resolve GDScript node lookup aliases
KirisamaMarisa May 24, 2026
1a18449
Report resolved edge counts after indexing
KirisamaMarisa May 24, 2026
4e89228
Preserve Godot scene containment
KirisamaMarisa May 24, 2026
78f3a0c
Merge remote-tracking branch 'origin/main' into godot-364-rebased
i-snyder Jun 30, 2026
fec2247
fix(resolution): resolve GDScript node-path references to Godot scene…
i-snyder Jul 1, 2026
8476ada
chore: bump EXTRACTION_VERSION for GDScript/Godot support
i-snyder Jul 1, 2026
1337983
docs(changelog): add GDScript/Godot support entry (#364)
i-snyder Jul 1, 2026
957c0c2
fix(cli): match MCP's language check for Godot scene-instance components
i-snyder Jul 1, 2026
509096f
Merge origin/main into godot-364-rebased
i-snyder Jul 1, 2026
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
1 change: 1 addition & 0 deletions .cursor/rules/codegraph.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Reach for `codegraph_explore` before grep/find or Read for any **structural** qu
- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context.
- **Don't grep or Read first** to find or understand indexed code — one `codegraph_explore` returns the relevant source in a single round-trip. Reach for raw Read/Grep only to confirm a specific detail codegraph didn't cover, or for what it doesn't index (configs, docs).
- **Index lag — check the staleness banner, don't guess a wait.** When a codegraph response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Files NOT in that banner are fresh and codegraph is authoritative for them.
- **Godot projects**: `res://...` resource paths and scene node names are valid query targets too.

### If `.codegraph/` doesn't exist

Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### New Features

- CodeGraph now indexes GDScript and Godot scene/resource files (`.gd`, `.tscn`, `.tres`), so Godot 4 projects get the same code intelligence as every other supported language. GDScript classes, methods, signals, constants, and variables are extracted with full type signatures, and Godot scenes are parsed into their own node graph — scene node containment, `ExtResource`/instanced-scene references, and a script's `res://` path all resolve to the real files and symbols they point to. Cross-file GDScript call graphs, `codegraph query`, `codegraph callers`, and the MCP tools all work against Godot projects as a result — previously CodeGraph produced no index at all for `.gd`/`.tscn`/`.tres` files. Thanks @KirisamaMarisa for the original GDScript/Godot support. (#364)

### Fixes

- C++ forward declarations no longer crowd out the real class definition. A `class Foo;` forward declaration — common in large C++ and Unreal Engine codebases, where a heavily used class is forward-declared across dozens of headers — was indexed as its own class node every time it appeared. So exploring that class returned mostly forward-declaration sites, and could even pick one of them as the representative for blast-radius, burying the actual definition and its members and callers. Bodiless forward declarations are now skipped for C and C++, exactly as forward-declared structs and enums already were, so only the real definition is indexed. Languages where a class with no body is a complete definition — such as Kotlin's `class Empty` and Scala — are unaffected. Thanks @luoyxy for the report and root-cause analysis. (#1093)
- C++ methods that return a reference, and user-defined conversion operators, are now indexed under their correct names. An inline getter like `const FGameplayTagContainer& GetActiveTags() const` — everywhere in Unreal Engine headers — was indexed as `& GetActiveTags() const` instead of `GetActiveTags`, and a conversion operator like `operator EALSMovementState() const` kept its trailing `() const` instead of reading `operator EALSMovementState`. In both cases the garbled name meant you couldn't find the symbol by name and its callers weren't linked. Both now read cleanly, matching how pointer-returning and value-returning methods already worked. (#1096)


## [1.1.6] - 2026-06-30

### Fixes
Expand Down
249 changes: 249 additions & 0 deletions __tests__/extraction.test.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions __tests__/integration/full-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ describe('Integration: full pipeline', () => {
const statsAfterIndex = cg.getStats();
expect(statsAfterIndex.fileCount).toBeGreaterThanOrEqual(MODULE_COUNT);
expect(statsAfterIndex.nodeCount).toBeGreaterThan(MODULE_COUNT * 2);
expect(indexResult.edgesCreated).toBe(statsAfterIndex.edgeCount);

// ── resolveReferences ────────────────────────────────────────
// Many call-site edges are wired up during extraction itself, so
Expand Down
162 changes: 162 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { UnresolvedRef } from '../src/resolution/types';
import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks';
import { QueryBuilder } from '../src/db/queries';
import { DatabaseConnection } from '../src/db';
import { ToolHandler } from '../src/mcp/tools';

describe('Resolution Module', () => {
let tempDir: string;
Expand Down Expand Up @@ -83,6 +84,167 @@ describe('Resolution Module', () => {
expect(result?.resolvedBy).toBe('exact-match');
});

it('should match Godot res:// file path references', () => {
const fileNode: Node = {
id: 'file:core/cards/card_resource.gd',
kind: 'file',
name: 'card_resource.gd',
qualifiedName: 'core/cards/card_resource.gd',
filePath: 'core/cards/card_resource.gd',
language: 'gdscript',
startLine: 1,
endLine: 10,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};

const context: ResolutionContext = {
getNodesInFile: () => [fileNode],
getNodesByName: (name) => name === 'card_resource.gd' ? [fileNode] : [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => true,
readFile: () => null,
getProjectRoot: () => '/test',
getAllFiles: () => ['core/cards/card_resource.gd'],
};

const ref = {
fromNodeId: 'file:data/cards/ace.tres',
referenceName: 'res://core/cards/card_resource.gd',
referenceKind: 'references' as const,
line: 4,
column: 10,
filePath: 'data/cards/ace.tres',
language: 'godot_resource' as const,
};

const result = matchReference(ref, context);

expect(result).not.toBeNull();
expect(result?.targetNodeId).toBe('file:core/cards/card_resource.gd');
expect(result?.resolvedBy).toBe('file-path');
});

it('should find MCP callers when queried with a Godot res:// path', async () => {
fs.mkdirSync(path.join(tempDir, 'runtime'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'runtime/run_state.gd'),
'class_name RunState\nextends RefCounted\n'
);
fs.writeFileSync(
path.join(tempDir, 'main.gd'),
'const RunStateScript := preload("res://runtime/run_state.gd")\n'
);

cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const handler = new ToolHandler(cg);

const result = await handler.execute('codegraph_callers', {
symbol: 'res://runtime/run_state.gd',
projectPath: tempDir,
});

const text = result.content[0]?.text ?? '';
expect(result.isError).not.toBe(true);
expect(text).toContain('main.gd');
});

it('should resolve GDScript node path references to Godot scene nodes', async () => {
fs.writeFileSync(
path.join(tempDir, 'status_view.gd'),
[
'extends Control',
'@onready var _template: Control = $MarginContainer/StatusFlow/StatusIconTemplate',
'@onready var _track: Control = get_node("%TrackPanel")',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tempDir, 'status_view.tscn'),
[
'[gd_scene load_steps=2 format=3]',
'[ext_resource type="Script" path="res://status_view.gd" id="1_status"]',
'[node name="StatusView" type="Control"]',
'script = ExtResource("1_status")',
'[node name="MarginContainer" type="MarginContainer" parent="."]',
'[node name="StatusFlow" type="HFlowContainer" parent="MarginContainer"]',
'[node name="StatusIconTemplate" type="Control" parent="MarginContainer/StatusFlow"]',
'[node name="TrackPanel" type="Control" parent="."]',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tempDir, 'other_view.tscn'),
[
'[gd_scene format=3]',
'[node name="OtherView" type="Control"]',
'[node name="StatusIconTemplate" type="Control" parent="."]',
'[node name="TrackPanel" type="Control" parent="."]',
'',
].join('\n')
);

cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const handler = new ToolHandler(cg);

const templateResult = await handler.execute('codegraph_callers', {
symbol: 'StatusIconTemplate',
projectPath: tempDir,
});
const trackResult = await handler.execute('codegraph_callers', {
symbol: 'TrackPanel',
projectPath: tempDir,
});

expect(templateResult.content[0]?.text ?? '').toContain('_template');
expect(trackResult.content[0]?.text ?? '').toContain('_track');
});

it('should include Godot scene instances when querying callers by instance node name', async () => {
fs.writeFileSync(
path.join(tempDir, 'battle_status.gd'),
'class_name BattleStatusView\nextends Control\n'
);
fs.writeFileSync(
path.join(tempDir, 'battle_status.tscn'),
[
'[gd_scene load_steps=2 format=3]',
'[ext_resource type="Script" path="res://battle_status.gd" id="1_status_script"]',
'[node name="BattleStatusView" type="Control"]',
'script = ExtResource("1_status_script")',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tempDir, 'control_middle.tscn'),
[
'[gd_scene load_steps=2 format=3]',
'[ext_resource type="PackedScene" path="res://battle_status.tscn" id="1_status_scene"]',
'[node name="ControlMiddle" type="Control"]',
'[node name="BattleStatusView" parent="." instance=ExtResource("1_status_scene")]',
'',
].join('\n')
);

cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const handler = new ToolHandler(cg);

const result = await handler.execute('codegraph_callers', {
symbol: 'BattleStatusView',
projectPath: tempDir,
});

const text = result.content[0]?.text ?? '';
expect(result.isError).not.toBe(true);
expect(text).toContain('control_middle.tscn:4');
expect(text).toContain('BattleStatusView (component)');
});

it('should prefer same-module candidates over cross-module matches', () => {
// Simulates a Python monorepo where multiple apps define navigate()
const candidateA: Node = {
Expand Down
2 changes: 2 additions & 0 deletions __tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,8 @@ describe('Source file detection (isSourceFile)', () => {
expect(isSourceFile('src/component.tsx')).toBe(true);
expect(isSourceFile('lib/util.js')).toBe(true);
expect(isSourceFile('src/main.py')).toBe(true);
expect(isSourceFile('scripts/player.gd')).toBe(true);
expect(isSourceFile('scenes/main.tscn')).toBe(true);
});

it('rejects unsupported extensions and extensionless files', () => {
Expand Down
66 changes: 60 additions & 6 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,56 @@ async function loadCodeGraph(): Promise<typeof import('../index')> {
// Dynamic import helper — tsc compiles import() to require() in CJS mode,
// which fails for ESM-only packages. This bypasses the transformation.
// eslint-disable-next-line @typescript-eslint/no-implied-eval
function normalizeSymbolQuery(symbol: string): string {
if (symbol.startsWith('res://')) return symbol.slice('res://'.length);
return symbol;
}

function lastSymbolQueryPart(symbol: string): string {
const slashIndex = symbol.lastIndexOf('/');
if (slashIndex >= 0) return symbol.slice(slashIndex + 1);
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
return parts[parts.length - 1] ?? symbol;
}

type CliSearchNode = {
id: string;
name: string;
kind: string;
filePath: string;
qualifiedName: string;
startLine?: number;
signature?: string;
language?: string;
};

function nodeMatchesSymbol(node: CliSearchNode, symbol: string): boolean {
const normalizedSymbol = normalizeSymbolQuery(symbol);
if (node.name === normalizedSymbol) return true;
if (node.kind === 'file' && (node.filePath === normalizedSymbol || node.qualifiedName === normalizedSymbol)) return true;
if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === normalizedSymbol) return true;
return node.name.endsWith(`.${normalizedSymbol}`) || node.name.endsWith(`::${normalizedSymbol}`);
}

function findCliSymbolMatches(
cg: { searchNodes: (query: string, options: { limit: number }) => Array<{ node: CliSearchNode }> },
symbol: string
) {
const normalizedSymbol = normalizeSymbolQuery(symbol);
let matches = cg.searchNodes(normalizedSymbol, { limit: 50 });
if (matches.length === 0 && /[.\/]|::/.test(normalizedSymbol)) {
const tail = lastSymbolQueryPart(normalizedSymbol);
if (tail && tail !== normalizedSymbol) matches = cg.searchNodes(tail, { limit: 50 });
}
const exactMatches = matches.filter((match) => nodeMatchesSymbol(match.node, normalizedSymbol));
return exactMatches.length > 0 ? exactMatches : matches;
}

function isGodotSceneInstanceComponent(node: CliSearchNode): boolean {
const signature = 'signature' in node && typeof node.signature === 'string' ? node.signature : '';
return node.kind === 'component' && node.language === 'godot_resource' && node.filePath.endsWith('.tscn') && signature.includes('instance=ExtResource');
}

const importESM = new Function('specifier', 'return import(specifier)') as
(specifier: string) => Promise<typeof import('@clack/prompts')>;

Expand Down Expand Up @@ -1618,7 +1668,7 @@ program
const cg = await CodeGraph.open(projectPath);
const limit = parseInt(options.limit || '20', 10);

const matches = cg.searchNodes(symbol, { limit: 50 });
const matches = findCliSymbolMatches(cg, symbol);
if (matches.length === 0) {
info(`Symbol "${symbol}" not found`);
cg.destroy();
Expand All @@ -1629,8 +1679,12 @@ program
const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];

for (const match of matches) {
const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
const exactMatch = nodeMatchesSymbol(match.node, symbol);
if (!exactMatch && matches.length > 1) continue;
if (exactMatch && isGodotSceneInstanceComponent(match.node) && !seen.has(match.node.id)) {
seen.add(match.node.id);
allCallers.push({ name: match.node.name, kind: match.node.kind, filePath: match.node.filePath, startLine: match.node.startLine });
}
for (const c of cg.getCallers(match.node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
Expand Down Expand Up @@ -1697,7 +1751,7 @@ program
const cg = await CodeGraph.open(projectPath);
const limit = parseInt(options.limit || '20', 10);

const matches = cg.searchNodes(symbol, { limit: 50 });
const matches = findCliSymbolMatches(cg, symbol);
if (matches.length === 0) {
info(`Symbol "${symbol}" not found`);
cg.destroy();
Expand All @@ -1708,7 +1762,7 @@ program
const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];

for (const match of matches) {
const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
const exactMatch = nodeMatchesSymbol(match.node, symbol);
if (!exactMatch && matches.length > 1) continue;
for (const c of cg.getCallees(match.node.id)) {
if (!seen.has(c.node.id)) {
Expand Down Expand Up @@ -1775,7 +1829,7 @@ program
const cg = await CodeGraph.open(projectPath);
const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10);

const matches = cg.searchNodes(symbol, { limit: 50 });
const matches = findCliSymbolMatches(cg, symbol);
if (matches.length === 0) {
info(`Symbol "${symbol}" not found`);
cg.destroy();
Expand All @@ -1788,7 +1842,7 @@ program
let edgeCount = 0;

for (const match of matches) {
const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
const exactMatch = nodeMatchesSymbol(match.node, symbol);
if (!exactMatch && matches.length > 1) continue;
const impact = cg.getImpactRadius(match.node.id, depth);
for (const [id, n] of impact.nodes) {
Expand Down
2 changes: 1 addition & 1 deletion src/extraction/extraction-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 24;
export const EXTRACTION_VERSION = 25;
Loading