diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc index 17d144a60..843302900 100644 --- a/.cursor/rules/codegraph.mdc +++ b/.cursor/rules/codegraph.mdc @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d0866c69b..ded2ee394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index cacc854b2..966f985ae 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -94,6 +94,16 @@ describe('Language Detection', () => { expect(detectLanguage('main.dart')).toBe('dart'); }); + it('should detect GDScript files', () => { + expect(detectLanguage('player.gd')).toBe('gdscript'); + }); + + it('should detect Godot resource files', () => { + expect(detectLanguage('main.tscn')).toBe('godot_resource'); + expect(detectLanguage('card.tres')).toBe('godot_resource'); + expect(detectLanguage('project.godot')).toBe('godot_resource'); + }); + it('should detect Objective-C files', () => { expect(detectLanguage('AppDelegate.m')).toBe('objc'); expect(detectLanguage('ViewController.mm')).toBe('objc'); @@ -130,6 +140,245 @@ describe('Language Support', () => { expect(languages).toContain('swift'); expect(languages).toContain('kotlin'); expect(languages).toContain('dart'); + expect(languages).toContain('gdscript'); + expect(languages).toContain('godot_resource'); + }); +}); + +describe('GDScript Extraction', () => { + it('should extract GDScript classes, methods, variables, and references', () => { + const code = ` +extends Node +class_name PlayerController + +signal health_changed(value: int) +const MAX_HP := 100 +const DYNAMIC_UI_SOUND_CONTROLLER_NAME := "MainDynamicUISoundController" +const WRAPPED_LABEL_NAME := "CardRarity" +const TEMPLATE_PATH := "MarginContainer/StatusFlow/StatusIconTemplate" +const CARD_ROW_PATH := "RewardList/CardRewardTemplate" +@onready var sprite := $Sprite2D +@export_range(0.0, 1.0, 0.1) var move_ratio := 0.5 +static var shared_counter := 0 + +func _ready() -> void: + var enemy = preload("res://enemy.gd") + var tint = Color(1, 0, 0) + $Sprite2D.play() + %StatusPanel.refresh() + var template = $MarginContainer/StatusFlow/StatusIconTemplate + var template_from_const = get_node_or_null(TEMPLATE_PATH) + var row_from_const = get_node_or_null(CARD_ROW_PATH) + var sound_controller = get_node_or_null(DYNAMIC_UI_SOUND_CONTROLLER_NAME) + var track = get_node("%TrackPanel") + var title_label = card_view.find_child("CardTitle", true, false) + var type_label = _find_label("CardType") + var rarity_label = _find_label(WRAPPED_LABEL_NAME) + var local_child_name := &"ChildBadge" + var child_badge = card_view.find_child(local_child_name, true, false) + var local_button_name := "DeckButton" + var deck_button = _find_node(root, local_button_name) + var controller_from_helper = _find_node(root, "MainDynamicUISoundController") + var row_name := "CardReward%d" % reward_index + var extra_row = get_node_or_null("CardReward%d" % reward_index) + var existing = get_node_or_null(row_name) + var reward_button = get_node_or_null("LootCardRewardButton") + reward_button = Button.new() + reward_button.name = "LootCardRewardButton" + $Sprite2D.pressed.connect(_on_sprite_pressed) + connect("health_changed", Callable(self, "_on_health_changed")) + setup_player() + +func setup_player() -> void: + health_changed.emit(MAX_HP) + emit_signal("health_changed", MAX_HP) + +func _on_sprite_pressed() -> void: + pass + +func _on_health_changed(value: int) -> void: + pass + +func _find_label(label_name: String) -> Label: + return root.find_child(label_name, true, false) as Label +`; + const result = extractFromSource('player_controller.gd', code); + + const classNode = result.nodes.find((n) => n.kind === 'class' && n.name === 'PlayerController'); + expect(classNode).toBeDefined(); + expect(classNode?.language).toBe('gdscript'); + + expect(result.nodes.some((n) => n.kind === 'method' && n.name === '_ready')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'setup_player')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'constant' && n.name === 'MAX_HP')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'constant' && n.name === 'DYNAMIC_UI_SOUND_CONTROLLER_NAME')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'constant' && n.name === 'WRAPPED_LABEL_NAME')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'constant' && n.name === 'TEMPLATE_PATH')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'constant' && n.name === 'CARD_ROW_PATH')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'sprite')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'move_ratio')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'shared_counter')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'health_changed')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'MainDynamicUISoundController')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'CardReward')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'LootCardRewardButton')).toBe(true); + + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'extends' && r.referenceName === 'Node')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'res://enemy.gd')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'Sprite2D.play')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'StatusPanel.refresh')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'health_changed.emit')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'health_changed')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'Sprite2D.pressed')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'pressed')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === '_on_sprite_pressed')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === '_on_health_changed')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'Sprite2D')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'StatusPanel')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'StatusIconTemplate')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'MarginContainer/StatusFlow/StatusIconTemplate')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'PlayerController/MarginContainer/StatusFlow/StatusIconTemplate')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'CardRewardTemplate')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'RewardList/CardRewardTemplate')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'PlayerController/RewardList/CardRewardTemplate')).toBe(true); + expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'references' && r.referenceName === 'MainDynamicUISoundController')).toHaveLength(2); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'CardTitle')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'CardType')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'CardRarity')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'ChildBadge')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'DeckButton')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'TrackPanel')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'PlayerController/TrackPanel')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'CardReward')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'PlayerController/CardReward')).toBe(true); + expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'references' && r.referenceName === 'CardReward')).toHaveLength(3); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'LootCardRewardButton')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'Color')).toBe(false); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'setup_player')).toBe(true); + }); + + it('should extract annotated class_name and inline extends declarations', () => { + const code = ` +@tool class_name EditorPanel extends MarginContainer + +@rpc("any_peer") func sync_state() -> void: + emit_changed() + +class InnerPanel extends Control: + func render() -> void: + pass +`; + const result = extractFromSource('editor_panel.gd', code); + + expect(result.nodes.some((n) => n.kind === 'class' && n.name === 'EditorPanel')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'sync_state')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'class' && n.name === 'InnerPanel')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'render')).toBe(true); + + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'extends' && r.referenceName === 'MarginContainer')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'extends' && r.referenceName === 'Control')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'emit_changed')).toBe(true); + }); + + it('should create an implicit script class for extends-only GDScript files', () => { + const code = ` +extends Control + +func _ready() -> void: + setup() + +func setup() -> void: + pass +`; + const result = extractFromSource('battle_hud.gd', code); + + expect(result.nodes.some((n) => n.kind === 'class' && n.name === 'BattleHud')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.name === '_ready')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'setup')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'extends' && r.referenceName === 'Control')).toBe(true); + }); +}); + +describe('Godot Resource Extraction', () => { + it('should extract Godot scene nodes and external resource references', () => { + const code = ` +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://player_controller.gd" id="1_script"] +[ext_resource type="PackedScene" path="res://status_icon_template.tscn" id="2_status"] + +[node name="Player" type="Node2D"] +script = ExtResource("1_script") + +[node name="Sprite2D" type="Sprite2D" parent="."] + +[node name="StatusIcon" parent="." instance=ExtResource("2_status")] + +[connection signal="pressed" from="Sprite2D" to="." method="_on_sprite_pressed"] +`; + const result = extractFromSource('player.tscn', code); + + expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'Player')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'Sprite2D')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'StatusIcon')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'import' && n.name === 'res://player_controller.gd')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'res://player_controller.gd')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'PlayerController')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'res://status_icon_template.tscn')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'StatusIconTemplate')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'StatusIcon')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'res://status_icon_template.tscn' && result.nodes.some((n) => n.id === r.fromNodeId && n.name === 'StatusIcon'))).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'StatusIconTemplate' && result.nodes.some((n) => n.id === r.fromNodeId && n.name === 'StatusIcon'))).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'StatusIcon' && result.nodes.some((n) => n.id === r.fromNodeId && n.name === 'StatusIcon'))).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === '_on_sprite_pressed')).toBe(true); + expect(result.edges.some((e) => e.kind === 'references' && e.metadata?.method === '_on_sprite_pressed')).toBe(true); + }); + + it('should preserve nested Godot scene node containment', () => { + const code = ` +[gd_scene format=3] + +[node name="StatusView" type="Control"] +[node name="MarginContainer" type="MarginContainer" parent="."] +[node name="StatusFlow" type="HFlowContainer" parent="MarginContainer"] +[node name="StatusIconTemplate" type="Control" parent="MarginContainer/StatusFlow"] +`; + const result = extractFromSource('status_view.tscn', code); + const nodeByName = new Map(result.nodes.map((node) => [node.name, node])); + + const contains = (sourceName: string, targetName: string): boolean => { + const source = nodeByName.get(sourceName); + const target = nodeByName.get(targetName); + return Boolean(source && target && result.edges.some((edge) => ( + edge.kind === 'contains' && + edge.source === source.id && + edge.target === target.id + ))); + }; + + expect(contains('StatusView', 'MarginContainer')).toBe(true); + expect(contains('MarginContainer', 'StatusFlow')).toBe(true); + expect(contains('StatusFlow', 'StatusIconTemplate')).toBe(true); + }); + + it('should extract Godot resource scripts and content ids', () => { + const code = ` +[gd_resource type="Resource" script_class="CardResource" format=3] + +[ext_resource type="Script" path="res://core/cards/card_resource.gd" id="1_card"] + +[resource] +script = ExtResource("1_card") +id = &"ace" +card_id = &"knife" +`; + const result = extractFromSource('data/cards/ace.tres', code); + + expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'resource')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'constant' && n.name === 'ace')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'constant' && n.name === 'knife')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'CardResource')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'res://core/cards/card_resource.gd')).toBe(true); }); }); diff --git a/__tests__/integration/full-pipeline.test.ts b/__tests__/integration/full-pipeline.test.ts index 5b551c136..0c274c270 100644 --- a/__tests__/integration/full-pipeline.test.ts +++ b/__tests__/integration/full-pipeline.test.ts @@ -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 diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index a6d455499..858a6d9b9 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -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; @@ -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 = { diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 3b3171782..355bb0a49 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -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', () => { diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index acef88d06..037cf90d2 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -55,6 +55,56 @@ async function loadCodeGraph(): Promise { // 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; @@ -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(); @@ -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); @@ -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(); @@ -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)) { @@ -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(); @@ -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) { diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 618a1b1c3..07ccbb964 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -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; diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts new file mode 100644 index 000000000..fa5692b17 --- /dev/null +++ b/src/extraction/gdscript-extractor.ts @@ -0,0 +1,768 @@ +import * as path from 'path'; +import { Edge, ExtractionError, ExtractionResult, Node, NodeKind, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; + +interface Scope { + id: string; + indent: number; + kind: NodeKind; +} + +interface FunctionScope extends Scope { + startLine: number; +} + +const KEYWORDS = new Set([ + 'if', + 'elif', + 'for', + 'while', + 'match', + 'return', + 'await', + 'assert', + 'print', + 'push_error', + 'push_warning', + 'preload', + 'load', + 'super', + 'func', + 'signal', +]); + +const ANNOTATION_PREFIX = '(?:(?:@\\w+(?:\\([^)]*\\))?)\\s+)*'; + +const GODOT_BUILT_IN_CALLS = new Set([ + 'AABB', + 'Array', + 'Basis', + 'Callable', + 'Color', + 'Dictionary', + 'NodePath', + 'PackedByteArray', + 'PackedColorArray', + 'PackedFloat32Array', + 'PackedFloat64Array', + 'PackedInt32Array', + 'PackedInt64Array', + 'PackedScene', + 'PackedStringArray', + 'PackedVector2Array', + 'PackedVector3Array', + 'Plane', + 'Projection', + 'Quaternion', + 'Rect2', + 'Rect2i', + 'RID', + 'Signal', + 'String', + 'StringName', + 'Transform2D', + 'Transform3D', + 'Vector2', + 'Vector2i', + 'Vector3', + 'Vector3i', + 'Vector4', + 'Vector4i', +]); + +/** + * Lightweight GDScript extractor. + * + * This intentionally avoids a hard dependency on a GDScript WASM grammar while + * still giving Godot projects useful symbol search and reference edges. + */ +export class GDScriptExtractor { + private filePath: string; + private source: string; + private lines: string[]; + private nodes: Node[] = []; + private edges: Edge[] = []; + private unresolvedReferences: UnresolvedReference[] = []; + private errors: ExtractionError[] = []; + private stringConstants = new Map(); + private dynamicNodeNames = new Set(); + private nodePathAliases = new Map>(); + private nodeLookupHelperArgumentIndex = new Map(); + + constructor(filePath: string, source: string) { + this.filePath = filePath; + this.source = source; + this.lines = source.split('\n'); + } + + extract(): ExtractionResult { + const startTime = Date.now(); + + try { + const fileNode = this.createFileNode(); + const scriptClass = this.extractScriptClass(fileNode) ?? this.extractImplicitScriptClass(fileNode); + this.extractDeclarations(fileNode, scriptClass); + this.extractReferences(fileNode, scriptClass); + } catch (error) { + this.errors.push({ + message: `GDScript extraction error: ${error instanceof Error ? error.message : String(error)}`, + filePath: this.filePath, + severity: 'error', + code: 'parse_error', + }); + } + + return { + nodes: this.nodes, + edges: this.edges, + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - startTime, + }; + } + + private createFileNode(): Node { + const node: Node = { + id: `file:${this.filePath}`, + kind: 'file', + name: path.basename(this.filePath), + qualifiedName: this.filePath, + filePath: this.filePath, + language: 'gdscript', + startLine: 1, + endLine: this.lines.length, + startColumn: 0, + endColumn: this.lines[this.lines.length - 1]?.length ?? 0, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + private extractScriptClass(fileNode: Node): Node | null { + const classNameMatch = this.source.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}class_name\\s+([A-Za-z_]\\w*)`, 'm')); + if (!classNameMatch) return null; + + const index = classNameMatch.index ?? 0; + const line = this.getLineNumber(index); + const column = index - this.getLineStart(line) + classNameMatch[0].indexOf(classNameMatch[1]!); + const name = classNameMatch[1]!; + const node = this.createNode('class', name, `${this.filePath}::${name}`, line, column, line, column + classNameMatch[0].trimEnd().length); + this.addContains(fileNode.id, node.id); + return node; + } + + private extractImplicitScriptClass(fileNode: Node): Node | null { + const extendsMatch = this.source.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}extends\\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\\w.]*))`, 'm')); + if (!extendsMatch) return null; + + const index = extendsMatch.index ?? 0; + const line = this.getLineNumber(index); + const name = this.scriptClassNameFromPath(); + const column = index - this.getLineStart(line); + const node = this.createNode('class', name, `${this.filePath}::${name}`, line, column, line, column + (this.lines[line - 1]?.trimEnd().length ?? 0)); + node.signature = `implicit script class extends ${extendsMatch[1] || extendsMatch[2] || extendsMatch[3]}`; + this.addContains(fileNode.id, node.id); + return node; + } + + private extractDeclarations(fileNode: Node, scriptClass: Node | null): void { + const scopes: Scope[] = [{ id: scriptClass?.id ?? fileNode.id, indent: -1, kind: scriptClass ? 'class' : 'file' }]; + + for (let i = 0; i < this.lines.length; i++) { + const lineNumber = i + 1; + const rawLine = this.lines[i] ?? ''; + const code = this.stripComment(rawLine); + if (!code.trim()) continue; + + const indent = this.indentOf(rawLine); + while (scopes.length > 1 && indent <= scopes[scopes.length - 1]!.indent) { + scopes.pop(); + } + + const trimmed = code.trim(); + if (new RegExp(`^${ANNOTATION_PREFIX}class_name\\s+`).test(trimmed)) continue; + + const classMatch = trimmed.match(new RegExp(`^${ANNOTATION_PREFIX}class\\s+([A-Za-z_]\\w*)\\s*(?:extends\\s+[^:]+)?\\s*:?`)); + if (classMatch) { + const node = this.createDeclarationNode('class', classMatch[1]!, rawLine, lineNumber, indent); + this.addContains(scopes[scopes.length - 1]!.id, node.id); + scopes.push({ id: node.id, indent, kind: 'class' }); + continue; + } + + const enumMatch = trimmed.match(/^enum(?:\s+([A-Za-z_]\w*))?/); + if (enumMatch) { + const name = enumMatch[1] || ''; + const node = this.createDeclarationNode('enum', name, rawLine, lineNumber, indent); + this.addContains(scopes[scopes.length - 1]!.id, node.id); + continue; + } + + const signalMatch = trimmed.match(/^signal\s+([A-Za-z_]\w*)/); + if (signalMatch) { + const node = this.createDeclarationNode('function', signalMatch[1]!, rawLine, lineNumber, indent); + node.signature = trimmed; + this.addContains(scopes[scopes.length - 1]!.id, node.id); + continue; + } + + const funcMatch = trimmed.match(new RegExp(`^${ANNOTATION_PREFIX}(?:static\\s+)?func\\s+([A-Za-z_]\\w*)\\s*(\\([^)]*\\))?(?:\\s*->\\s*([^:]+))?`)); + if (funcMatch) { + const insideClass = scopes.some((scope) => scope.kind === 'class'); + const node = this.createDeclarationNode(insideClass ? 'method' : 'function', funcMatch[1]!, rawLine, lineNumber, indent); + node.signature = `${funcMatch[2] || '()'}${funcMatch[3] ? ` -> ${funcMatch[3].trim()}` : ''}`; + node.isStatic = /\bstatic\s+func\b/.test(trimmed); + this.addContains(scopes[scopes.length - 1]!.id, node.id); + scopes.push({ id: node.id, indent, kind: node.kind }); + continue; + } + + const varMatch = trimmed.match(new RegExp(`^${ANNOTATION_PREFIX}(?:static\\s+)?(var|const)\\s+([A-Za-z_]\\w*)`)); + if (varMatch) { + const kind: NodeKind = varMatch[1] === 'const' ? 'constant' : 'variable'; + const node = this.createDeclarationNode(kind, varMatch[2]!, rawLine, lineNumber, indent); + node.signature = trimmed; + this.addContains(scopes[scopes.length - 1]!.id, node.id); + if (kind === 'constant') { + const stringValueMatch = trimmed.match(/:=?\s*&?["']([^"']+)["']/); + if (stringValueMatch) { + const constName = varMatch[2]!; + const stringValue = stringValueMatch[1]!; + this.stringConstants.set(constName, stringValue); + if (/_NAME$/.test(constName) && this.isSimpleNodeName(stringValue)) { + this.addDynamicNodeNameDeclaration(stringValue, rawLine, trimmed, lineNumber, scopes[scopes.length - 1]!.id); + } + } + } + } + + const dynamicNodeNameMatch = trimmed.match(/\b[A-Za-z_]\w*\s*\.\s*name\s*=\s*["']([A-Za-z_]\w*)["']/); + if (dynamicNodeNameMatch) { + this.addDynamicNodeNameDeclaration(dynamicNodeNameMatch[1]!, rawLine, trimmed, lineNumber, scopes[scopes.length - 1]!.id); + } + + const formattedNodeName = this.extractFormattedNodePathBase(trimmed); + if (formattedNodeName) { + this.addDynamicNodeNameDeclaration(formattedNodeName, rawLine, trimmed, lineNumber, scopes[scopes.length - 1]!.id); + } + } + } + + private extractReferences(fileNode: Node, scriptClass: Node | null): void { + const functionScopes = this.nodes + .filter((node) => (node.kind === 'function' || node.kind === 'method') && node.language === 'gdscript') + .map((node) => ({ id: node.id, indent: this.indentOf(this.lines[node.startLine - 1] ?? ''), kind: node.kind, startLine: node.startLine } as FunctionScope)) + .sort((a, b) => a.startLine - b.startLine); + this.extractNodeLookupHelpers(functionScopes); + const declarationByLine = new Map(); + for (const node of this.nodes) { + if ((node.kind === 'variable' || node.kind === 'constant') && node.language === 'gdscript') { + declarationByLine.set(node.startLine, node); + } + } + + const functionOwnerForLine = (line: number, indent: number): string => { + let owner = scriptClass?.id ?? fileNode.id; + for (const scope of functionScopes) { + if (scope.startLine < line && scope.indent < indent) { + owner = scope.id; + } + } + return owner; + }; + + const ownerForLine = (line: number, indent: number): string => { + const sameLineDeclaration = declarationByLine.get(line); + if (sameLineDeclaration) return sameLineDeclaration.id; + + return functionOwnerForLine(line, indent); + }; + + for (let i = 0; i < this.lines.length; i++) { + const lineNumber = i + 1; + const rawLine = this.lines[i] ?? ''; + const code = this.stripComment(rawLine); + const indent = this.indentOf(rawLine); + const owner = ownerForLine(lineNumber, indent); + const functionOwner = functionOwnerForLine(lineNumber, indent); + + const extendsMatch = code.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}(?:(?:class_name|class)\\s+[A-Za-z_]\\w*\\s+)?extends\\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\\w.]*))`)); + if (extendsMatch) { + this.addReference(owner, extendsMatch[1] || extendsMatch[2] || extendsMatch[3]!, 'extends', lineNumber, code.indexOf('extends')); + } + + const resourceRegex = /\b(?:preload|load)\s*\(\s*["']([^"']+)["']\s*\)/g; + let resourceMatch; + while ((resourceMatch = resourceRegex.exec(code)) !== null) { + this.addReference(owner, resourceMatch[1]!, 'references', lineNumber, resourceMatch.index); + } + + this.extractNodePathReferences(owner, code, lineNumber, scriptClass, functionOwner); + this.extractSignalReferences(functionOwner, code, lineNumber); + this.extractCallableReferences(functionOwner, code, lineNumber); + + const memberCallRegex = /(?:\b([A-Za-z_]\w*)|([$%][A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*))\s*\.\s*([A-Za-z_]\w*)\s*\(/g; + let memberCallMatch; + while ((memberCallMatch = memberCallRegex.exec(code)) !== null) { + const receiver = memberCallMatch[1] || this.nodePathReceiverName(memberCallMatch[2]!); + const method = memberCallMatch[3]!; + if (KEYWORDS.has(method)) continue; + this.addReference(owner, `${receiver}.${method}`, 'calls', lineNumber, memberCallMatch.index); + } + + const callRegex = /\b([A-Za-z_]\w*)\s*\(/g; + let callMatch; + while ((callMatch = callRegex.exec(code)) !== null) { + const name = callMatch[1]!; + const prefix = code.slice(Math.max(0, callMatch.index - 8), callMatch.index); + if ( + KEYWORDS.has(name) || + GODOT_BUILT_IN_CALLS.has(name) || + /\.\s*$/.test(prefix) || + /\bfunc\s+$/.test(prefix) || + /\bsignal\s+$/.test(prefix) + ) continue; + this.addReference(owner, name, 'calls', lineNumber, callMatch.index); + } + } + } + + private extractSignalReferences(owner: string, code: string, lineNumber: number): void { + this.extractSignalConnectReferences(owner, code, lineNumber); + this.extractSignalEmitReferences(owner, code, lineNumber); + } + + private extractSignalConnectReferences(owner: string, code: string, lineNumber: number): void { + const memberConnectRegex = /\b(?:([A-Za-z_]\w*)|([$%][A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*))\s*\.\s*([A-Za-z_]\w*)\s*\.\s*connect\s*\(/g; + let memberConnectMatch; + while ((memberConnectMatch = memberConnectRegex.exec(code)) !== null) { + const receiver = memberConnectMatch[1] || this.nodePathReceiverName(memberConnectMatch[2]!); + const signalName = memberConnectMatch[3]!; + this.addReference(owner, signalName, 'references', lineNumber, memberConnectMatch.index); + this.addReference(owner, `${receiver}.${signalName}`, 'references', lineNumber, memberConnectMatch.index); + + const argsStart = memberConnectRegex.lastIndex; + const argsEnd = this.findCallEnd(code, argsStart - 1); + if (argsEnd > argsStart) { + this.addCallableTargetReferences(owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); + } + } + + const bareConnectRegex = /\b([A-Za-z_]\w*)\s*\.\s*connect\s*\(/g; + let bareConnectMatch; + while ((bareConnectMatch = bareConnectRegex.exec(code)) !== null) { + const signalName = bareConnectMatch[1]!; + if (signalName === 'node') continue; + this.addReference(owner, signalName, 'references', lineNumber, bareConnectMatch.index); + + const argsStart = bareConnectRegex.lastIndex; + const argsEnd = this.findCallEnd(code, argsStart - 1); + if (argsEnd > argsStart) { + this.addCallableTargetReferences(owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); + } + } + + const legacyConnectRegex = /\bconnect\s*\(\s*(?:&)?["']([^"']+)["']\s*,/g; + let legacyConnectMatch; + while ((legacyConnectMatch = legacyConnectRegex.exec(code)) !== null) { + this.addReference(owner, legacyConnectMatch[1]!, 'references', lineNumber, legacyConnectMatch.index); + + const argsStart = legacyConnectRegex.lastIndex; + const argsEnd = this.findCallEnd(code, code.indexOf('(', legacyConnectMatch.index)); + if (argsEnd > argsStart) { + this.addCallableTargetReferences(owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); + } + } + } + + private extractSignalEmitReferences(owner: string, code: string, lineNumber: number): void { + const memberEmitRegex = /\b([A-Za-z_]\w*)\s*\.\s*emit\s*\(/g; + let memberEmitMatch; + while ((memberEmitMatch = memberEmitRegex.exec(code)) !== null) { + this.addReference(owner, memberEmitMatch[1]!, 'calls', lineNumber, memberEmitMatch.index); + } + + const emitSignalRegex = /\bemit_signal\s*\(\s*(?:&)?["']([^"']+)["']/g; + let emitSignalMatch; + while ((emitSignalMatch = emitSignalRegex.exec(code)) !== null) { + this.addReference(owner, emitSignalMatch[1]!, 'calls', lineNumber, emitSignalMatch.index); + } + } + + private addCallableTargetReferences(owner: string, args: string, lineNumber: number, argsColumn: number): void { + const callableRegex = /\bCallable\s*\(\s*(?:self|this|[A-Za-z_]\w*)\s*,\s*["']([A-Za-z_]\w*)["']\s*\)/g; + let callableMatch; + while ((callableMatch = callableRegex.exec(args)) !== null) { + this.addReference(owner, callableMatch[1]!, 'calls', lineNumber, argsColumn + callableMatch.index); + } + + const directHandlerMatch = args.match(/^\s*([A-Za-z_]\w*)\b/); + if (directHandlerMatch) { + const name = directHandlerMatch[1]!; + if (!KEYWORDS.has(name) && !GODOT_BUILT_IN_CALLS.has(name) && name !== 'func') { + this.addReference(owner, name, 'calls', lineNumber, argsColumn + args.indexOf(name)); + } + } + } + + private extractCallableReferences(owner: string, code: string, lineNumber: number): void { + const callableRegex = /\bCallable\s*\(\s*(?:self|this|[A-Za-z_]\w*)\s*,\s*["']([A-Za-z_]\w*)["']\s*\)/g; + let callableMatch; + while ((callableMatch = callableRegex.exec(code)) !== null) { + this.addReference(owner, callableMatch[1]!, 'calls', lineNumber, callableMatch.index); + } + } + + private extractNodePathReferences(owner: string, code: string, lineNumber: number, scriptClass: Node | null, aliasOwner: string): void { + this.extractStringNodePathAlias(aliasOwner, code); + + const shorthandRegex = /[$%]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/g; + let shorthandMatch; + while ((shorthandMatch = shorthandRegex.exec(code)) !== null) { + this.addNodePathReference(owner, shorthandMatch[1]!, lineNumber, shorthandMatch.index, scriptClass); + } + + const getNodeRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']+)["']\s*\)/g; + let getNodeMatch; + while ((getNodeMatch = getNodeRegex.exec(code)) !== null) { + this.addNodePathReference(owner, getNodeMatch[1]!, lineNumber, getNodeMatch.index, scriptClass); + } + + const findChildRegex = /\bfind_child\s*\(\s*["']([^"']+)["']/g; + let findChildMatch; + while ((findChildMatch = findChildRegex.exec(code)) !== null) { + this.addNodePathReference(owner, findChildMatch[1]!, lineNumber, findChildMatch.index, scriptClass); + } + + const findChildAliasRegex = /\bfind_child\s*\(\s*([A-Za-z_]\w*)\b/g; + let findChildAliasMatch; + while ((findChildAliasMatch = findChildAliasRegex.exec(code)) !== null) { + const nodePath = this.resolveStringAlias(aliasOwner, findChildAliasMatch[1]!); + if (!nodePath) continue; + this.addNodePathReference(owner, nodePath, lineNumber, findChildAliasMatch.index, scriptClass); + } + + const projectFindNodeRegex = /\b_find_node\s*\(\s*[^,\n]+,\s*["']([^"']+)["']/g; + let projectFindNodeMatch; + while ((projectFindNodeMatch = projectFindNodeRegex.exec(code)) !== null) { + this.addNodePathReference(owner, projectFindNodeMatch[1]!, lineNumber, projectFindNodeMatch.index, scriptClass); + } + + const projectFindNodeAliasRegex = /\b_find_node\s*\(\s*[^,\n]+,\s*([A-Za-z_]\w*)\b/g; + let projectFindNodeAliasMatch; + while ((projectFindNodeAliasMatch = projectFindNodeAliasRegex.exec(code)) !== null) { + const nodePath = this.resolveStringAlias(aliasOwner, projectFindNodeAliasMatch[1]!); + if (!nodePath) continue; + this.addNodePathReference(owner, nodePath, lineNumber, projectFindNodeAliasMatch.index, scriptClass); + } + + const getNodeFormattedRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']*%d[^"']*)["']\s*%/g; + let getNodeFormattedMatch; + while ((getNodeFormattedMatch = getNodeFormattedRegex.exec(code)) !== null) { + const formattedNodePath = this.formattedNodePathBase(getNodeFormattedMatch[1]!); + if (formattedNodePath) { + this.addNodePathReference(owner, formattedNodePath, lineNumber, getNodeFormattedMatch.index, scriptClass); + } + } + + const formattedNodePathVariableRegex = /\b[A-Za-z_]\w*(?:_name|_path)\s*:=?\s*["']([^"']*%d[^"']*)["']\s*%/g; + let formattedNodePathVariableMatch; + while ((formattedNodePathVariableMatch = formattedNodePathVariableRegex.exec(code)) !== null) { + const formattedNodePath = this.formattedNodePathBase(formattedNodePathVariableMatch[1]!); + if (formattedNodePath) { + const variableName = (code.slice(formattedNodePathVariableMatch.index).match(/\b([A-Za-z_]\w*(?:_name|_path))\s*:=?/) || [])[1]; + if (variableName) this.addNodePathAlias(aliasOwner, variableName, formattedNodePath); + this.addNodePathReference(owner, formattedNodePath, lineNumber, formattedNodePathVariableMatch.index, scriptClass); + } + } + + const getNodeConstantRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*([A-Za-z_]\w*)\s*\)/g; + let getNodeConstantMatch; + while ((getNodeConstantMatch = getNodeConstantRegex.exec(code)) !== null) { + const constName = getNodeConstantMatch[1]!; + const nodePath = this.lookupNodePathAlias(aliasOwner, constName) ?? this.stringConstants.get(constName); + if (!nodePath) continue; + this.addNodePathReference(owner, nodePath, lineNumber, getNodeConstantMatch.index, scriptClass); + } + + const helperCallRegex = /\b([A-Za-z_]\w*)\s*\(([^)]*)\)/g; + let helperCallMatch; + while ((helperCallMatch = helperCallRegex.exec(code)) !== null) { + const helperName = helperCallMatch[1]!; + const argumentIndex = this.nodeLookupHelperArgumentIndex.get(helperName); + if (argumentIndex === undefined) continue; + const args = this.splitCallArguments(helperCallMatch[2]!); + const nodePath = this.resolveStringArgument(aliasOwner, args[argumentIndex]); + if (!nodePath) continue; + this.addNodePathReference(owner, nodePath, lineNumber, helperCallMatch.index, scriptClass); + } + } + + private extractStringNodePathAlias(owner: string, code: string): void { + const stringAliasRegex = /\b(?:var|const)\s+([A-Za-z_]\w*)\s*(?::\s*[A-Za-z_]\w*)?\s*:=?\s*&?["']([^"']+)["']/g; + let stringAliasMatch; + while ((stringAliasMatch = stringAliasRegex.exec(code)) !== null) { + const value = stringAliasMatch[2]!; + if (this.isLikelyNodePath(value)) { + this.addNodePathAlias(owner, stringAliasMatch[1]!, value); + } + } + } + + private resolveStringArgument(owner: string, argument: string | undefined): string | null { + if (!argument) return null; + const literal = argument.match(/^\s*&?["']([^"']+)["']\s*$/); + if (literal) return literal[1]!; + + const identifier = argument.match(/^\s*([A-Za-z_]\w*)\s*$/); + if (!identifier) return null; + return this.resolveStringAlias(owner, identifier[1]!); + } + + private resolveStringAlias(owner: string, name: string): string | null { + return this.lookupNodePathAlias(owner, name) ?? this.stringConstants.get(name) ?? null; + } + + private extractNodeLookupHelpers(functionScopes: FunctionScope[]): void { + if (this.nodeLookupHelperArgumentIndex.size > 0) return; + + for (let i = 0; i < functionScopes.length; i++) { + const scope = functionScopes[i]!; + const node = this.nodes.find((candidate) => candidate.id === scope.id); + if (!node) continue; + + const functionLine = this.stripComment(this.lines[scope.startLine - 1] ?? ''); + const params = this.extractFunctionParameterNames(functionLine); + if (params.length === 0) continue; + + const endLineExclusive = this.functionBodyEndLine(scope, functionScopes, i); + const body = this.lines + .slice(scope.startLine, endLineExclusive - 1) + .map((line) => this.stripComment(line)) + .join('\n'); + + for (let paramIndex = 0; paramIndex < params.length; paramIndex++) { + const paramName = params[paramIndex]!; + const escaped = this.escapeRegExp(paramName); + const directLookupRegex = new RegExp(`\\b(?:get_node|get_node_or_null|has_node|find_child)\\s*\\(\\s*${escaped}\\b`); + const projectLookupRegex = new RegExp(`\\b_find_node\\s*\\([^,\\n]+,\\s*${escaped}\\b`); + if (directLookupRegex.test(body) || projectLookupRegex.test(body)) { + this.nodeLookupHelperArgumentIndex.set(node.name, paramIndex); + break; + } + } + } + } + + private extractFunctionParameterNames(functionLine: string): string[] { + const match = functionLine.match(/\bfunc\s+[A-Za-z_]\w*\s*\(([^)]*)\)/); + if (!match) return []; + return this.splitCallArguments(match[1]!) + .map((arg) => (arg.trim().match(/^([A-Za-z_]\w*)/) || [])[1]) + .filter((name): name is string => Boolean(name)); + } + + private functionBodyEndLine(scope: FunctionScope, functionScopes: FunctionScope[], scopeIndex: number): number { + for (let i = scopeIndex + 1; i < functionScopes.length; i++) { + const next = functionScopes[i]!; + if (next.indent <= scope.indent) return next.startLine; + } + return this.lines.length + 1; + } + + private splitCallArguments(args: string): string[] { + const result: string[] = []; + let start = 0; + let depth = 0; + let inSingle = false; + let inDouble = false; + for (let i = 0; i < args.length; i++) { + const char = args[i]; + const prev = args[i - 1]; + if (char === "'" && !inDouble && prev !== '\\') inSingle = !inSingle; + if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble; + if (inSingle || inDouble) continue; + if (char === '(' || char === '[' || char === '{') depth += 1; + if (char === ')' || char === ']' || char === '}') depth -= 1; + if (char === ',' && depth === 0) { + result.push(args.slice(start, i)); + start = i + 1; + } + } + result.push(args.slice(start)); + return result; + } + + private escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + private addNodePathReference(owner: string, nodePath: string, lineNumber: number, column: number, scriptClass: Node | null): void { + const cleaned = nodePath.replace(/^[$%]/, ''); + const name = this.nodePathReceiverName(cleaned); + this.addReference(owner, name, 'references', lineNumber, column); + if (cleaned.includes('/')) { + this.addReference(owner, cleaned, 'references', lineNumber, column); + } + if (scriptClass && cleaned) { + this.addReference(owner, `${scriptClass.name}/${cleaned}`, 'references', lineNumber, column); + } + } + + private addDynamicNodeNameDeclaration(name: string, rawLine: string, signature: string, lineNumber: number, owner: string): void { + if (this.dynamicNodeNames.has(name)) return; + this.dynamicNodeNames.add(name); + const node = this.createNode( + 'component', + name, + `${this.filePath}::dynamic_node:${name}:${lineNumber}`, + lineNumber, + rawLine.indexOf(name), + lineNumber, + rawLine.length + ); + node.signature = signature; + this.addContains(owner, node.id); + } + + private addNodePathAlias(owner: string, alias: string, nodePath: string): void { + if (!this.nodePathAliases.has(owner)) this.nodePathAliases.set(owner, new Map()); + this.nodePathAliases.get(owner)!.set(alias, nodePath); + } + + private lookupNodePathAlias(owner: string, alias: string): string | undefined { + return this.nodePathAliases.get(owner)?.get(alias); + } + + private extractFormattedNodePathBase(code: string): string | null { + if (!/\b(?:get_node|get_node_or_null|has_node)\s*\(/.test(code) && !/\b[A-Za-z_]\w*\s*:=?\s*["'][^"']*%d/.test(code)) { + return null; + } + + const formattedStringMatch = code.match(/["']([^"']*%d[^"']*)["']\s*%/); + if (!formattedStringMatch) return null; + return this.formattedNodePathBase(formattedStringMatch[1]!); + } + + private formattedNodePathBase(nodePath: string): string | null { + if (!nodePath.includes('%d')) return null; + const stripped = nodePath.replace(/%d/g, ''); + if (!/^[A-Z_][A-Za-z0-9_]*(?:\/[A-Z_][A-Za-z0-9_]*)*$/.test(stripped)) return null; + return stripped; + } + + private isSimpleNodeName(value: string): boolean { + return /^[A-Z_][A-Za-z0-9_]*$/.test(value); + } + + private isLikelyNodePath(value: string): boolean { + return /^[A-Z_][A-Za-z0-9_]*(?:\/[A-Z_][A-Za-z0-9_]*)*$/.test(value); + } + + private createDeclarationNode(kind: NodeKind, name: string, rawLine: string, line: number, indent: number): Node { + const column = rawLine.indexOf(name); + return this.createNode(kind, name, `${this.filePath}::${name}`, line, column < 0 ? indent : column, line, rawLine.length); + } + + private createNode(kind: NodeKind, name: string, qualifiedName: string, startLine: number, startColumn: number, endLine: number, endColumn: number): Node { + const node: Node = { + id: generateNodeId(this.filePath, kind, name, startLine), + kind, + name, + qualifiedName, + filePath: this.filePath, + language: 'gdscript', + startLine, + endLine, + startColumn, + endColumn, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + private addContains(source: string, target: string): void { + this.edges.push({ source, target, kind: 'contains' }); + } + + private addReference(fromNodeId: string, referenceName: string, referenceKind: UnresolvedReference['referenceKind'], line: number, column: number): void { + this.unresolvedReferences.push({ + fromNodeId, + referenceName, + referenceKind, + line, + column, + filePath: this.filePath, + language: 'gdscript', + }); + } + + private indentOf(line: string): number { + let indent = 0; + for (const char of line) { + if (char === ' ') indent += 1; + else if (char === '\t') indent += 4; + else break; + } + return indent; + } + + private stripComment(line: string): string { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const prev = line[i - 1]; + if (char === "'" && !inDouble && prev !== '\\') inSingle = !inSingle; + if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble; + if (char === '#' && !inSingle && !inDouble) return line.slice(0, i); + } + return line; + } + + private findCallEnd(code: string, openingParenIndex: number): number { + let depth = 0; + let inSingle = false; + let inDouble = false; + for (let i = openingParenIndex; i < code.length; i++) { + const char = code[i]; + const prev = code[i - 1]; + if (char === "'" && !inDouble && prev !== '\\') inSingle = !inSingle; + if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble; + if (inSingle || inDouble) continue; + if (char === '(') depth += 1; + if (char === ')') { + depth -= 1; + if (depth === 0) return i; + } + } + return code.length; + } + + private getLineNumber(index: number): number { + return this.source.substring(0, index).split('\n').length; + } + + private getLineStart(line: number): number { + let pos = 0; + for (let i = 1; i < line; i++) { + pos += (this.lines[i - 1]?.length ?? 0) + 1; + } + return pos; + } + + private scriptClassNameFromPath(): string { + const base = path.basename(this.filePath, path.extname(this.filePath)); + const words = base.split(/[^A-Za-z0-9]+/).filter(Boolean); + const pascal = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(''); + return pascal || path.basename(this.filePath); + } + + private nodePathReceiverName(nodePath: string): string { + const cleaned = nodePath.replace(/^[$%]/, ''); + const lastSegment = cleaned.split('/').filter(Boolean).pop(); + return lastSegment || cleaned || nodePath; + } +} diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts new file mode 100644 index 000000000..36f27dbc0 --- /dev/null +++ b/src/extraction/godot-resource-extractor.ts @@ -0,0 +1,337 @@ +import * as path from 'path'; +import { Edge, ExtractionError, ExtractionResult, Node, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; + +/** + * Lightweight extractor for Godot text resources (.tscn, .tres, project.godot). + */ +export class GodotResourceExtractor { + private filePath: string; + private source: string; + private lines: string[]; + private nodes: Node[] = []; + private edges: Edge[] = []; + private unresolvedReferences: UnresolvedReference[] = []; + private referenceKeys = new Set(); + private errors: ExtractionError[] = []; + private extResources = new Map(); + private nodesByScenePath = new Map(); + private rootNode: Node | null = null; + + constructor(filePath: string, source: string) { + this.filePath = filePath; + this.source = source; + this.lines = source.split('\n'); + } + + extract(): ExtractionResult { + const startTime = Date.now(); + + try { + const fileNode = this.createFileNode(); + this.extractSections(fileNode.id); + } catch (error) { + this.errors.push({ + message: `Godot resource extraction error: ${error instanceof Error ? error.message : String(error)}`, + filePath: this.filePath, + severity: 'error', + code: 'parse_error', + }); + } + + return { + nodes: this.nodes, + edges: this.edges, + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - startTime, + }; + } + + private createFileNode(): Node { + const node: Node = { + id: `file:${this.filePath}`, + kind: 'file', + name: path.basename(this.filePath), + qualifiedName: this.filePath, + filePath: this.filePath, + language: 'godot_resource', + startLine: 1, + endLine: this.lines.length, + startColumn: 0, + endColumn: this.lines[this.lines.length - 1]?.length ?? 0, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + private extractSections(fileNodeId: string): void { + let currentOwner: Node | null = null; + + for (let i = 0; i < this.lines.length; i++) { + const line = this.lines[i] ?? ''; + const lineNumber = i + 1; + const section = line.match(/^\[([A-Za-z_]+)([^\]]*)\]/); + if (!section) { + if (currentOwner) this.extractSectionProperty(currentOwner, line, lineNumber); + continue; + } + + const type = section[1]!; + const attrs = this.parseAttributes(section[2] ?? ''); + if (type === 'node') { + const name = attrs.get('name') || ''; + const nodeType = attrs.get('type'); + const scenePath = this.scenePathForNode(name, attrs.get('parent')); + const node = this.createNode('component', name, `${this.filePath}::node:${scenePath}`, lineNumber, 0, line.length); + node.signature = nodeType ? `[node name="${name}" type="${nodeType}"]` : line.trim(); + if (!attrs.has('parent') && !this.rootNode) this.rootNode = node; + this.nodesByScenePath.set(scenePath, node); + this.addNodeContainment(fileNodeId, node, attrs.get('parent')); + this.extractNodeInstanceReference(node, attrs, line, lineNumber); + currentOwner = node; + } else if (type === 'ext_resource') { + const resourcePath = attrs.get('path'); + const id = attrs.get('id'); + if (!resourcePath) continue; + if (id) this.extResources.set(id, resourcePath); + const node = this.createNode('import', resourcePath, `${this.filePath}::ext_resource:${resourcePath}`, lineNumber, 0, line.length); + node.signature = line.trim(); + this.addContains(fileNodeId, node.id); + this.addReference(fileNodeId, resourcePath, 'references', lineNumber, line.indexOf(resourcePath)); + currentOwner = null; + } else if (type === 'sub_resource') { + const id = attrs.get('id') || `line:${lineNumber}`; + const resourceType = attrs.get('type') || 'sub_resource'; + const node = this.createNode('component', id, `${this.filePath}::sub_resource:${id}`, lineNumber, 0, line.length); + node.signature = `[sub_resource type="${resourceType}" id="${id}"]`; + this.addContains(fileNodeId, node.id); + currentOwner = node; + } else if (type === 'resource') { + const node = this.createNode('component', 'resource', `${this.filePath}::resource`, lineNumber, 0, line.length); + node.signature = line.trim(); + this.addContains(fileNodeId, node.id); + currentOwner = node; + } else if (type === 'gd_resource') { + const scriptClass = attrs.get('script_class'); + if (scriptClass) { + this.addReference(fileNodeId, scriptClass, 'references', lineNumber, line.indexOf(scriptClass)); + } + currentOwner = null; + } else if (type === 'connection') { + this.extractConnection(fileNodeId, attrs, line, lineNumber); + currentOwner = null; + } else { + currentOwner = null; + } + } + + this.extractInlineResourcePaths(fileNodeId); + } + + private extractNodeInstanceReference(owner: Node, attrs: Map, line: string, lineNumber: number): void { + const instance = attrs.get('instance'); + if (!instance) return; + + const extResourceMatch = instance.match(/^ExtResource\("([^"]+)"\)$/); + if (!extResourceMatch) return; + + const resourcePath = this.extResources.get(extResourceMatch[1]!); + if (!resourcePath) return; + + this.addReference(owner.id, resourcePath, 'references', lineNumber, line.indexOf('instance=')); + this.addGodotResourceAliasReference(owner.id, resourcePath, 'references', lineNumber, line.indexOf('instance=')); + this.addGodotInstanceNameAliasReference(owner, 'references', lineNumber, line.indexOf('instance=')); + } + + private extractSectionProperty(owner: Node, line: string, lineNumber: number): void { + const scriptMatch = line.match(/^\s*script\s*=\s*ExtResource\("([^"]+)"\)/); + if (scriptMatch) { + const resourcePath = this.extResources.get(scriptMatch[1]!); + if (resourcePath) { + this.addReference(owner.id, resourcePath, 'references', lineNumber, line.indexOf('ExtResource')); + this.addGodotResourceAliasReference(owner.id, resourcePath, 'references', lineNumber, line.indexOf('ExtResource')); + } + return; + } + + const idMatch = line.match(/^\s*(id|content_id|card_id|relic_id|enemy_id|event_id|status_id|encounter_id|pool_id)\s*=\s*&?"([^"]+)"/); + if (!idMatch) return; + + const value = idMatch[2]!; + const node = this.createNode('constant', value, `${this.filePath}::${idMatch[1]}:${value}`, lineNumber, line.indexOf(value), line.length); + node.signature = line.trim(); + this.addContains(owner.id, node.id); + } + + private extractConnection(fileNodeId: string, attrs: Map, line: string, lineNumber: number): void { + const method = attrs.get('method'); + if (!method) return; + + const fromNode = this.resolveSceneNode(attrs.get('from') || '.'); + const toNode = this.resolveSceneNode(attrs.get('to') || '.'); + const ownerId = fromNode?.id ?? fileNodeId; + this.addReference(ownerId, method, 'calls', lineNumber, line.indexOf(method)); + + if (toNode) { + this.edges.push({ + source: ownerId, + target: toNode.id, + kind: 'references', + line: lineNumber, + column: line.indexOf('to='), + provenance: 'heuristic', + metadata: { + signal: attrs.get('signal'), + method, + }, + }); + } + } + + private addNodeContainment(fileNodeId: string, node: Node, parent: string | undefined): void { + if (!parent) { + this.addContains(fileNodeId, node.id); + return; + } + + const parentNode = this.resolveSceneNode(parent || '.'); + this.addContains(parentNode?.id ?? fileNodeId, node.id); + } + + private scenePathForNode(name: string, parent: string | undefined): string { + if (!parent) return name; + + const parentPath = this.normalizeScenePath(parent || '.'); + if (parentPath === '.') return this.rootNode ? `${this.rootNode.name}/${name}` : name; + return `${parentPath}/${name}`; + } + + private normalizeScenePath(scenePath: string): string { + if (!scenePath || scenePath === '.') return '.'; + return scenePath.replace(/^\.\//, ''); + } + + private resolveSceneNode(scenePath: string): Node | null { + const normalized = this.normalizeScenePath(scenePath); + if (normalized === '.') return this.rootNode; + + const direct = this.nodesByScenePath.get(normalized); + if (direct) return direct; + + if (this.rootNode) { + return this.nodesByScenePath.get(`${this.rootNode.name}/${normalized}`) ?? null; + } + + return null; + } + + private extractInlineResourcePaths(fileNodeId: string): void { + const pathRegex = /["'](res:\/\/[^"']+)["']/g; + let match; + while ((match = pathRegex.exec(this.source)) !== null) { + const resourcePath = match[1]; + if (!resourcePath) continue; + const line = this.getLineNumber(match.index); + this.addReference(fileNodeId, resourcePath, 'references', line, match.index - this.getLineStart(line)); + } + } + + private parseAttributes(text: string): Map { + const attrs = new Map(); + const attrRegex = /([A-Za-z_]\w*)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g; + let match; + while ((match = attrRegex.exec(text)) !== null) { + attrs.set(match[1]!, match[2] ?? match[3] ?? match[4] ?? ''); + } + return attrs; + } + + private createNode(kind: Node['kind'], name: string, qualifiedName: string, line: number, startColumn: number, endColumn: number): Node { + const node: Node = { + id: generateNodeId(this.filePath, kind, name, line), + kind, + name, + qualifiedName, + filePath: this.filePath, + language: 'godot_resource', + startLine: line, + endLine: line, + startColumn, + endColumn, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + private addContains(source: string, target: string): void { + this.edges.push({ source, target, kind: 'contains' }); + } + + private addReference(fromNodeId: string, referenceName: string, referenceKind: UnresolvedReference['referenceKind'], line: number, column: number): void { + const key = `${fromNodeId}:${referenceKind}:${referenceName}`; + if (this.referenceKeys.has(key)) return; + this.referenceKeys.add(key); + + this.unresolvedReferences.push({ + fromNodeId, + referenceName, + referenceKind, + line, + column, + filePath: this.filePath, + language: 'godot_resource', + }); + } + + private addGodotResourceAliasReference( + fromNodeId: string, + resourcePath: string, + referenceKind: UnresolvedReference['referenceKind'], + line: number, + column: number + ): void { + const alias = this.godotClassNameFromResourcePath(resourcePath); + if (!alias) return; + this.addReference(fromNodeId, alias, referenceKind, line, column); + } + + private godotClassNameFromResourcePath(resourcePath: string): string | null { + const withoutProtocol = resourcePath.replace(/^res:\/\//, ''); + const ext = path.extname(withoutProtocol); + if (ext !== '.gd' && ext !== '.tscn') return null; + + const baseName = path.basename(withoutProtocol, ext); + const words = baseName.split(/[^A-Za-z0-9]+/).filter(Boolean); + const alias = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(''); + return alias || null; + } + + private addGodotInstanceNameAliasReference( + owner: Node, + referenceKind: UnresolvedReference['referenceKind'], + line: number, + column: number + ): void { + if (!this.isLikelyGodotClassName(owner.name)) return; + this.addReference(owner.id, owner.name, referenceKind, line, column); + } + + private isLikelyGodotClassName(name: string): boolean { + return /^[A-Z][A-Za-z0-9]*$/.test(name); + } + + private getLineNumber(index: number): number { + return this.source.substring(0, index).split('\n').length; + } + + private getLineStart(line: number): number { + let pos = 0; + for (let i = 1; i < line; i++) { + pos += (this.lines[i - 1]?.length ?? 0) + 1; + } + return pos; + } +} diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 1b15996c0..cb4615e96 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import { Parser, Language as WasmLanguage } from 'web-tree-sitter'; import { Language } from '../types'; -export type GrammarLanguage = Exclude; +export type GrammarLanguage = Exclude; /** * WASM filename map — maps each language to its .wasm grammar file @@ -106,6 +106,10 @@ export const EXTENSION_MAP: Record = { '.sc': 'scala', '.lua': 'lua', '.luau': 'luau', + '.gd': 'gdscript', + '.tscn': 'godot_resource', + '.tres': 'godot_resource', + '.godot': 'godot_resource', '.m': 'objc', '.mm': 'objc', // XML: file-level tracking; the MyBatis extractor matches `` @@ -322,6 +326,8 @@ export function isLanguageSupported(language: Language): boolean { if (language === 'vue') return true; // custom extractor (script block delegation) if (language === 'astro') return true; // custom extractor (frontmatter/script block delegation) if (language === 'liquid') return true; // custom regex extractor + if (language === 'gdscript') return true; // custom Godot/GDScript extractor + if (language === 'godot_resource') return true; // custom Godot scene/resource extractor if (language === 'razor') return true; // custom RazorExtractor (.cshtml/.razor markup) if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver if (language === 'twig') return true; // file-level tracking only @@ -335,7 +341,7 @@ export function isLanguageSupported(language: Language): boolean { * Check if a grammar has been loaded and is ready for parsing. */ export function isGrammarLoaded(language: Language): boolean { - if (language === 'svelte' || language === 'vue' || language === 'astro' || language === 'liquid' || language === 'razor') return true; + if (language === 'svelte' || language === 'vue' || language === 'astro' || language === 'liquid' || language === 'razor' || language === 'gdscript' || language === 'godot_resource') return true; if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed if (language === 'xml' || language === 'properties') return true; // no WASM grammar needed return languageCache.has(language); @@ -358,7 +364,7 @@ export function isFileLevelOnlyLanguage(language: Language): boolean { * Get all supported languages (those with grammar definitions). */ export function getSupportedLanguages(): Language[] { - return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'vue', 'astro', 'liquid']; + return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'vue', 'astro', 'liquid', 'gdscript', 'godot_resource']; } /** @@ -431,6 +437,8 @@ export function getLanguageDisplayName(language: Language): string { scala: 'Scala', lua: 'Lua', luau: 'Luau', + gdscript: 'GDScript', + godot_resource: 'Godot Resource', objc: 'Objective-C', yaml: 'YAML', twig: 'Twig', diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 695c467fb..cee78d0e0 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -28,6 +28,8 @@ import { SvelteExtractor } from './svelte-extractor'; import { AstroExtractor } from './astro-extractor'; import { DfmExtractor } from './dfm-extractor'; import { VueExtractor } from './vue-extractor'; +import { GDScriptExtractor } from './gdscript-extractor'; +import { GodotResourceExtractor } from './godot-resource-extractor'; import { MyBatisExtractor } from './mybatis-extractor'; import { getAllFrameworkResolvers, @@ -5746,6 +5748,14 @@ export function extractFromSource( // Use custom extractor for Liquid const extractor = new LiquidExtractor(filePath, source); result = extractor.extract(); + } else if (detectedLanguage === 'gdscript') { + // Use custom extractor for GDScript + const extractor = new GDScriptExtractor(filePath, source); + result = extractor.extract(); + } else if (detectedLanguage === 'godot_resource') { + // Use custom extractor for Godot text scenes/resources + const extractor = new GodotResourceExtractor(filePath, source); + result = extractor.extract(); } else if (detectedLanguage === 'razor') { // Use custom extractor for ASP.NET Razor (.cshtml) / Blazor (.razor) markup const extractor = new RazorExtractor(filePath, source); diff --git a/src/installer/instructions-template.ts b/src/installer/instructions-template.ts index 924749190..11f1df0c6 100644 --- a/src/installer/instructions-template.ts +++ b/src/installer/instructions-template.ts @@ -47,5 +47,7 @@ In repositories indexed by CodeGraph (a \`.codegraph/\` directory exists at the - **MCP tool** (when available): \`codegraph_explore\` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search. - **Shell** (always works): \`codegraph explore ""\` prints the same output. +Godot projects: \`res://...\` resource paths and scene node names are valid query targets too. + If there is no \`.codegraph/\` directory, skip CodeGraph entirely — indexing is the user's decision. ${CODEGRAPH_SECTION_END}`; diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index 88f6f2e3f..bea635c92 100644 --- a/src/mcp/server-instructions.ts +++ b/src/mcp/server-instructions.ts @@ -52,6 +52,7 @@ calls; a grep/read exploration is dozens. - **Almost any question — "how does X work", architecture, a bug, "what/where is X", or surveying an area** → \`codegraph_explore\` with a natural-language question or the relevant names. ONE capped call returns the verbatim source grouped by file; most often the ONLY call you need. - **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, riding dynamic-dispatch hops, and returns their source. - **Reading or editing a file/symbol you can name** → put its name or file path in the \`codegraph_explore\` query — it returns that current line-numbered source (safe to \`Edit\` from) with the call path and blast radius attached, so you don't Read it separately. For an overloaded name it returns every matching definition's body in one call. +- **Godot projects** — \`res://...\` resource paths and scene node names are valid query targets too: \`codegraph_explore\` resolves them alongside regular symbols. - **Need more?** Call \`codegraph_explore\` again with more specific names — treat the source it returns as already Read. ## Anti-patterns diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index ad7c02612..0d49222df 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -89,8 +89,16 @@ const CONTAINER_NODE_KINDS = new Set([ 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module', ]); +/** Normalize engine/framework path aliases users commonly type into tools. */ +function normalizeSymbolQuery(symbol: string): string { + if (symbol.startsWith('res://')) return symbol.slice('res://'.length); + return symbol; +} + /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */ function lastQualifierPart(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; } @@ -1557,6 +1565,10 @@ export class ToolHandler { const callers: Node[] = []; const labels = new Map(); for (const node of defNodes) { + if (this.isGodotSceneInstanceComponent(node) && !seen.has(node.id)) { + seen.add(node.id); + callers.push(node); + } for (const c of cg.getCallers(node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); @@ -1604,6 +1616,13 @@ export class ToolHandler { return this.textResult(this.truncateOutput(lines.join('\n') + filterNote)); } + private isGodotSceneInstanceComponent(node: Node): boolean { + return node.kind === 'component' + && node.language === 'godot_resource' + && node.filePath.endsWith('.tscn') + && (node.signature ?? '').includes('instance=ExtResource'); + } + /** * Handle codegraph_callees */ @@ -4271,8 +4290,12 @@ export class ToolHandler { * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`) */ private matchesSymbol(node: Node, symbol: string): boolean { + symbol = normalizeSymbolQuery(symbol); + // Simple name match if (node.name === symbol) return true; + // File path match (e.g., Godot `res://runtime/run_state.gd`) + if (node.kind === 'file' && (node.filePath === symbol || node.qualifiedName === symbol)) return true; // File basename match (e.g., "product-card" matches "product-card.liquid") if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true; @@ -4367,21 +4390,22 @@ export class ToolHandler { * results across all matching symbols (e.g., multiple classes with an `execute` method). */ private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } { - let results = cg.searchNodes(symbol, { limit: 50 }); + const normalizedSymbol = normalizeSymbolQuery(symbol); + let results = cg.searchNodes(normalizedSymbol, { limit: 50 }); // Mirror the fallback in `findSymbol` for qualified queries — FTS // strips colons, so a module-qualified lookup needs a second pass // by the bare last part. - if (results.length === 0 && /[.\/]|::/.test(symbol)) { - const tail = lastQualifierPart(symbol); - if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 }); + if (results.length === 0 && /[.\/]|::/.test(normalizedSymbol)) { + const tail = lastQualifierPart(normalizedSymbol); + if (tail && tail !== normalizedSymbol) results = cg.searchNodes(tail, { limit: 50 }); } if (results.length === 0) { return { nodes: [], note: '' }; } - const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol)); + const exactMatches = results.filter(r => this.matchesSymbol(r.node, normalizedSymbol)); if (exactMatches.length <= 1) { const node = exactMatches[0]?.node ?? results[0]!.node; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c07632654..28d34eeed 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -42,16 +42,17 @@ export function matchByFilePath( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { + const referencePath = normalizePathReference(ref.referenceName); // Path-like (`a/b.liquid`) OR a bare filename ending in a short extension // (`Foo.h` — an Objective-C `#import "Foo.h"`, resolved to the header by // basename). A bare ref WITHOUT an extension is a symbol name, not a file, so // leave it to the symbol-matching strategies. - if (!ref.referenceName.includes('/') && !/\.[A-Za-z][A-Za-z0-9]{0,3}$/.test(ref.referenceName)) { + if (!referencePath.includes('/') && !/\.[A-Za-z][A-Za-z0-9]{0,3}$/.test(referencePath)) { return null; } // Extract the filename from the path - const fileName = ref.referenceName.split('/').pop(); + const fileName = referencePath.split('/').pop(); if (!fileName) return null; // Search for file nodes with this name @@ -61,7 +62,7 @@ export function matchByFilePath( if (fileNodes.length === 0) return null; // Prefer exact path match on qualified_name - const exactMatch = fileNodes.find(n => n.qualifiedName === ref.referenceName || n.filePath === ref.referenceName); + const exactMatch = fileNodes.find(n => n.qualifiedName === referencePath || n.filePath === referencePath); if (exactMatch) { return { original: ref, @@ -79,7 +80,7 @@ export function matchByFilePath( // bare-filename import) resolves relative to the including file, not to an // arbitrary same-named header elsewhere in the tree. const suffixMatches = fileNodes.filter( - n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName) + n => n.qualifiedName.endsWith(referencePath) || n.filePath.endsWith(referencePath) ); if (suffixMatches.length > 0) { return { @@ -103,6 +104,11 @@ export function matchByFilePath( return null; } +function normalizePathReference(referenceName: string): string { + if (referenceName.startsWith('res://')) return referenceName.slice('res://'.length); + return referenceName; +} + /** * Among several file nodes that all match a bare include/import by basename, * pick the one closest to the referencing file: same directory first, then by @@ -145,6 +151,10 @@ const LANGUAGE_FAMILY: Record = { // Razor/Blazor markup names C# types — same family so `@model Foo` / // `` resolve to their `.cs` class through the cross-family gate. csharp: 'dotnet', razor: 'dotnet', + // A GDScript node-path reference (`$MarginContainer/StatusIconTemplate`) + // names a node declared in a `.tscn` scene — same family so it resolves + // through the cross-family gate instead of being dropped as unrelated. + gdscript: 'godot', godot_resource: 'godot', }; export function sameLanguageFamily(a: string, b: string): boolean { if (a === b) return true; @@ -404,8 +414,8 @@ export function matchByQualifiedName( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { - // Check if the reference name looks qualified (contains :: or .) - if (!ref.referenceName.includes('::') && !ref.referenceName.includes('.')) { + // Check if the reference name looks qualified (contains ::, ., or a path segment) + if (!ref.referenceName.includes('::') && !ref.referenceName.includes('.') && !ref.referenceName.includes('/')) { return null; } @@ -421,7 +431,7 @@ export function matchByQualifiedName( } // Try partial qualified name match - const parts = ref.referenceName.split(/[:.]/); + const parts = ref.referenceName.split(/[:.\/]/); const lastName = parts[parts.length - 1]; if (lastName) { const partialCandidates = context.getNodesByName(lastName); diff --git a/src/types.ts b/src/types.ts index a3122bf9a..6e26585ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,6 +89,8 @@ export const LANGUAGES = [ 'scala', 'lua', 'luau', + 'gdscript', + 'godot_resource', 'objc', 'r', 'yaml',