diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc index 17d144a60..9e5733739 100644 --- a/.cursor/rules/codegraph.mdc +++ b/.cursor/rules/codegraph.mdc @@ -17,6 +17,8 @@ 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 symbols for callers/callees/impact queries. + ### If `.codegraph/` doesn't exist diff --git a/.gitignore b/.gitignore index 9bb977905..584ae0e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,8 @@ assets/__pycache__/ assets/generate-waitlist.py +.cate + .kommandr/ # Local scratch tests (never commit) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77aa5bf23..ccd2f56b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features +- **GDScript** is now parsed with a real tree-sitter grammar: classes, methods, signals, enums with members, constants, inner classes, static functions, and lambdas — with no phantom symbols from code-like text inside strings or comments. +- Godot flows now connect end-to-end through dynamic dispatch: autoload singletons (`GameState.reset()`), engine callbacks (`_ready`, `_process`, `_input`, …), string-keyed dispatch (`call("x")`, `Callable(self, "x")`, `has_method("x")`), and scene-signal connections — a button press links to its handler method even when that handler lives in another file. + ## [1.5.0] - 2026-07-21 diff --git a/README.md b/README.md index dccebd3e4..ba937465f 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **30+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi, GDScript | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -788,6 +788,8 @@ is written): | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | | R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) | | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | +| GDScript | `.gd` | Full support (classes, methods, signals, enums, inner classes, lambdas; Godot wiring: autoloads, engine callbacks, string-keyed dispatch, scene-signal connections) | +| Godot resources | `.tscn`, `.tres`, `project.godot` | Scene nodes with containment, script attachments, signal connections, autoload singletons | | CFML | `.cfc`, `.cfm`, `.cfs` | Full support (tag-based ``/`` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `` delegation, call edges) | | COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) | | Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) | diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 784023952..acf21c991 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -102,6 +102,16 @@ describe('Language Detection', () => { expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c'); }); + 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 Metal shader files as C++ (#1121)', () => { expect(detectLanguage('Shaders.metal')).toBe('cpp'); expect(isSourceFile('Renderer/Shaders.metal')).toBe(true); @@ -203,11 +213,453 @@ describe('Language Support', () => { expect(languages).toContain('swift'); expect(languages).toContain('kotlin'); expect(languages).toContain('dart'); + expect(languages).toContain('gdscript'); + expect(languages).toContain('godot_resource'); expect(languages).toContain('solidity'); expect(languages).toContain('nix'); }); }); +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 === 'signal' && 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); + }); + + it('should extract @tool script metadata', () => { + const code = `@tool +extends Node + +func _ready() -> void: + pass +`; + const result = extractFromSource('tool_script.gd', code); + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode?.decorators).toContain('tool'); + }); + + it('should extract call() and call_deferred() literal method references', () => { + const code = ` +extends Node + +func _ready() -> void: + call("setup_ui") + call_deferred("queue_free") + var dynamic = some_var + call(dynamic) +`; + const result = extractFromSource('dynamic_call.gd', code); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'setup_ui')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'queue_free')).toBe(true); + }); + + it('should extract tween_property string paths', () => { + const code = ` +extends Node + +func _ready() -> void: + var tween = create_tween() + tween.tween_property(some_node, "modulate:a", 0.5, 1.0) + tween.tween_method(self, "_update_value", 0.0, 1.0, 1.0) +`; + const result = extractFromSource('tween_test.gd', code); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'modulate:a')).toBe(true); + }); + + it('should mark static funcs and not create symbols for lambdas', () => { + const code = ` +extends Node + +class_name FactoryScript + +static func build_handler(prefix: String) -> Callable: + return func(item: String) -> void: + deliver(prefix, item) + +func deliver(prefix: String, item: String) -> void: + pass +`; + const result = extractFromSource('factory_script.gd', code); + + const builder = result.nodes.find((n) => n.kind === 'method' && n.name === 'build_handler'); + expect(builder?.isStatic).toBe(true); + expect(result.nodes.find((n) => n.kind === 'method' && n.name === 'deliver')?.isStatic).toBeFalsy(); + + // The lambda body's call still resolves through the enclosing method… + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'deliver')).toBe(true); + // …and the anonymous function itself produced NO symbol nodes. + const methodNames = result.nodes.filter((n) => n.kind === 'method' || n.kind === 'function').map((n) => n.name).sort(); + expect(methodNames).toEqual(['build_handler', 'deliver']); + }); + + it('should extract calls inside match statement branches', () => { + const code = ` +extends Node + +func handle_event(event_type: int) -> void: + match event_type: + 0: + start_game() + 1: + show_menu() + _: + log_unknown(event_type) + +func start_game() -> void: + pass +`; + const result = extractFromSource('event_router.gd', code); + for (const callee of ['start_game', 'show_menu', 'log_unknown']) { + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === callee)).toBe(true); + } + }); + + it('should extract enum members and inner class containment', () => { + const code = ` +@tool +class_name OuterPanel +extends MarginContainer + +enum Rarity { COMMON, RARE } + +class InnerPanel extends Control: + func render() -> void: + pass +`; + const result = extractFromSource('outer_panel_new.gd', code); + + expect(result.nodes.some((n) => n.kind === 'enum' && n.name === 'Rarity')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'enum_member' && n.name === 'COMMON')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'enum_member' && n.name === 'RARE')).toBe(true); + + const outer = result.nodes.find((n) => n.kind === 'class' && n.name === 'OuterPanel'); + expect(outer?.decorators).toContain('tool'); + const inner = result.nodes.find((n) => n.kind === 'class' && n.name === 'InnerPanel'); + const render = result.nodes.find((n) => n.kind === 'method' && n.name === 'render'); + expect(inner).toBeDefined(); + expect(render).toBeDefined(); + expect(result.edges.some((e) => e.kind === 'contains' && e.source === inner?.id && e.target === render?.id)).toBe(true); + }); + + it('should not create symbols from code-like text inside strings or comments', () => { + const code = ` +extends Node + +func _ready() -> void: + var snippet := "func phantom_method(): pass" + # ghost_call() mentioned only in a comment stays inert + real_call_target() + +func real_call_target() -> void: + pass +`; + const result = extractFromSource('robustness_check.gd', code); + + // AST-driven symbol extraction ignores string contents entirely. + expect(result.nodes.some((n) => (n.kind === 'method' || n.kind === 'function') && n.name === 'phantom_method')).toBe(false); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'real_call_target')).toBe(true); + // Comment-stripped lines never contribute references. + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'ghost_call')).toBe(false); + }); + + it('should extract has_method() string-dispatch targets as calls', () => { + const code = ` +extends Node + +func interact(target: Node) -> void: + if target.has_method("take_hit"): + target.call("take_hit") +`; + const result = extractFromSource('interactor.gd', code); + // has_method AND call() both bridge to the same method name. + expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls' && r.referenceName === 'take_hit').length).toBeGreaterThanOrEqual(2); + }); +}); + +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); + }); + + it('should extract unique_name_in_owner declarations', () => { + const code = ` +[gd_scene format=3] + +[node name="Root" type="Node"] +[node name="HealthLabel" type="Label" parent="."] +unique_name_in_owner = true +[node name="SomeButton" type="Button" parent="."] +`; + const result = extractFromSource('main.tscn', code); + + const healthLabel = result.nodes.find((n) => n.name === 'HealthLabel' && n.kind === 'component'); + expect(healthLabel).toBeDefined(); + }); + + it('should extract %(UniqueName) references in property values', () => { + const code = ` +[gd_scene format=3] + +[node name="Root" type="Node"] +[node name="HealthLabel" type="Label" parent="."] +unique_name_in_owner = true +[node name="Updater" type="Node" parent="."] +target = %HealthLabel +other_ref = %(SomeOther) +`; + const result = extractFromSource('ref_test.tscn', code); + + const updater = result.nodes.find((n) => n.name === 'Updater' && n.kind === 'component'); + expect(updater).toBeDefined(); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'HealthLabel' && r.fromNodeId === updater!.id)).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'SomeOther')).toBe(true); + }); + + it('should resolve %Name in connection from/to attrs', () => { + const code = ` +[gd_scene format=3] + +[node name="Root" type="Node"] +[node name="HealthLabel" type="Label" parent="."] +unique_name_in_owner = true +[node name="Updater" type="Node" parent="."] + +[connection signal="pressed" from="%HealthLabel" to="." method="_on_pressed"] +`; + const result = extractFromSource('conn_test.tscn', code); + + const healthLabel = result.nodes.find((n) => n.name === 'HealthLabel' && n.kind === 'component'); + const root = result.nodes.find((n) => n.name === 'Root' && n.kind === 'component'); + expect(healthLabel).toBeDefined(); + expect(root).toBeDefined(); + expect(result.edges.some((e) => e.kind === 'references' && e.source === healthLabel!.id && e.target === root!.id && e.metadata?.method === '_on_pressed')).toBe(true); + }); +}); + describe('Nix Extraction', () => { it('should distinguish Nix variable and function bindings', () => { const code = ` diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 3df4f2d88..47d70565d 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -1031,13 +1031,108 @@ describe('Go gRPC stub→impl synthesis', () => { }); }); -describe('React Router end-to-end route extraction (.tsx/.jsx)', () => { +describe('Godot end-to-end — engine virtuals, string dispatch, scene connections', () => { let tmpDir: string | undefined; afterEach(() => { if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; }); + it('synthesizes class → engine-virtual entry edges (godot-engine-virtual)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-gdvirtual-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'player_controller.gd'), + [ + 'extends CharacterBody2D', + '', + 'func _physics_process(delta: float) -> void:', + '\tmove_and_slide()', + '', + 'func apply_gravity() -> void:', + '\tpass', + '', + ].join('\n') + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + cg.resolveReferences(); + + // Implicit script class from the filename is the owner hub. + const cls = cg.getNodesByKind('class').find((n) => n.name === 'PlayerController'); + expect(cls).toBeDefined(); + const virtuals = cg + .getOutgoingEdges(cls!.id) + .filter((e) => e.kind === 'calls') + .filter((e) => (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === 'godot-engine-virtual') + .map((e) => cg.getNode(e.target)?.name); + expect(virtuals).toContain('_physics_process'); + expect(virtuals).not.toContain('apply_gravity'); + } finally { + cg?.close(); + } + }); + + it('bridges .tscn signal connections to handler methods across files (godot-scene-connection)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-gdscene-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'main.gd'), + [ + 'extends Control', + '', + 'func _on_start_pressed() -> void:', + '\tstart_game()', + '', + 'func start_game() -> void:', + '\tpass', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'main.tscn'), + [ + '[gd_scene load_steps=2 format=3]', + '[ext_resource type="Script" path="res://main.gd" id="1_main"]', + '[node name="Main" type="Control"]', + 'script = ExtResource("1_main")', + '[node name="StartButton" type="Button" parent="."]', + '[connection signal="pressed" from="StartButton" to="." method="_on_start_pressed"]', + '', + ].join('\n') + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + cg.resolveReferences(); + + const handler = cg.getNodesByKind('method').find((n) => n.name === '_on_start_pressed'); + expect(handler).toBeDefined(); + const inbound = cg.getIncomingEdges(handler!.id).filter((e) => e.kind === 'calls'); + const bridged = inbound.some( + (e) => + (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === 'godot-scene-connection' + ); + expect(bridged).toBe(true); + + // The full flow must connect: handler → start_game via the plain call. + const callees = cg.getCallees(handler!.id).map((c) => c.node.name); + expect(callees).toContain('start_game'); + } finally { + cg?.close(); + } + }); +}); + +describe('React Router end-to-end route extraction (.tsx/.jsx)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); // Regression for the resolver language-gate bug: the `react` resolver's // `extract()` was filtered out of the .tsx/.jsx grammars, so `` routes // — which only live in JSX files — were never indexed through the real diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index cc7e3555f..6833404f2 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import type { FrameworkResolver, UnresolvedRef } from '../src/resolution/types'; +import type { FrameworkResolver, UnresolvedRef, ResolutionContext } from '../src/resolution/types'; import type { Node } from '../src/types'; describe('FrameworkResolver.extract interface', () => { @@ -1772,3 +1772,118 @@ export class UsersController { expect(references.map((r) => r.referenceName)).toEqual(['real']); }); }); + +import { godotResolver } from '../src/resolution/frameworks/godot'; + +describe('godotResolver', () => { + it('detects Godot project by project.godot', () => { + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p) => p === 'project.godot', + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => ['project.godot'], + }; + expect(godotResolver.detect(context)).toBe(true); + }); + + it('detects Godot project by .tscn file', () => { + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => ['scenes/main.tscn', 'scripts/player.gd'], + }; + expect(godotResolver.detect(context)).toBe(true); + }); + + it('resolves res:// path to file node', () => { + const mockNodes: Node[] = [ + { + id: 'file:some/script.gd', + kind: 'file', + name: 'script.gd', + qualifiedName: 'some/script.gd', + filePath: 'some/script.gd', + language: 'gdscript', + startLine: 1, + endLine: 10, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }, + ]; + const context: ResolutionContext = { + getNodesInFile: (fp) => (fp.endsWith('some/script.gd') ? mockNodes : []), + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (fp) => fp.endsWith('some/script.gd'), + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + }; + const ref = { + fromNodeId: 'file:player.gd', + referenceName: 'res://some/script.gd', + referenceKind: 'references' as const, + line: 5, + column: 10, + filePath: 'player.gd', + language: 'gdscript' as const, + }; + const result = godotResolver.resolve(ref, context); + expect(result).not.toBeNull(); + expect(result!.targetNodeId).toBe('file:some/script.gd'); + expect(result!.resolvedBy).toBe('file-path'); + }); + + it('resolves %UniqueName to scene component node', () => { + const mockNodes: Node[] = [ + { + id: 'component:main.tscn:HealthLabel:3', + kind: 'component', + name: 'HealthLabel', + qualifiedName: 'main.tscn::node:Root/HealthLabel', + filePath: 'main.tscn', + language: 'godot_resource', + startLine: 3, + endLine: 3, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }, + ]; + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: (n) => n === 'HealthLabel' ? mockNodes : [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + }; + const ref = { + fromNodeId: 'file:player.gd', + referenceName: 'HealthLabel', + referenceKind: 'references' as const, + line: 5, + column: 10, + filePath: 'player.gd', + language: 'gdscript' as const, + }; + const result = godotResolver.resolve(ref, context); + expect(result).not.toBeNull(); + expect(result!.targetNodeId).toBe('component:main.tscn:HealthLabel:3'); + expect(result!.resolvedBy).toBe('framework'); + }); +}); + 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__/mcp-tool-allowlist.test.ts b/__tests__/mcp-tool-allowlist.test.ts index 8d342134e..cfcb19412 100644 --- a/__tests__/mcp-tool-allowlist.test.ts +++ b/__tests__/mcp-tool-allowlist.test.ts @@ -17,13 +17,13 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => { const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort(); - it('exposes ONLY codegraph_explore by default when unset', () => { + it('exposes the default surface (explore + references) when unset', () => { delete process.env[ENV]; - // The default set (see DEFAULT_MCP_TOOLS) is pared to explore alone — the one - // tool that earns its place (verbatim source grouped by file). - // node/search/callers/callees/impact/files/status stay defined and executable - // but unlisted; CODEGRAPH_MCP_TOOLS re-enables them. - expect(listed()).toEqual(['codegraph_explore']); + // The default set (see DEFAULT_MCP_TOOLS): explore is the one tool that + // earns its place; references stays listed as this fork's cross-ref + // finder. node/search/callers/callees/impact/files/status stay defined and + // executable but unlisted; CODEGRAPH_MCP_TOOLS re-enables them. + expect(listed()).toEqual(['codegraph_explore', 'codegraph_references']); }); it('re-enables an unlisted tool via the allowlist (impact)', () => { @@ -43,7 +43,7 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => { it('treats an empty/whitespace value as unset (default surface)', () => { process.env[ENV] = ' '; - expect(listed()).toEqual(['codegraph_explore']); + expect(listed()).toEqual(['codegraph_explore', 'codegraph_references']); }); it('rejects a disabled tool on execute (defense in depth)', async () => { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index eca1778ff..46d609f80 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,228 @@ 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('StatusIconTemplate'); + expect(trackResult.content[0]?.text ?? '').toContain('TrackPanel'); + }); + + it('should resolve autoload singleton receivers to their script methods', async () => { + fs.mkdirSync(path.join(tempDir, 'autoload'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'project.godot'), + [ + '[application]', + 'config/name="Demo"', + '', + '[autoload]', + '', + 'GameState="*res://autoload/game_state.gd"', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(tempDir, 'autoload/game_state.gd'), + [ + 'extends Node', + 'var score := 0', + '', + 'func reset() -> void:', + '\tscore = 0', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(tempDir, 'hud.gd'), + [ + 'extends Control', + '', + 'func _ready() -> void:', + '\tGameState.reset()', + '\tGameState.queue_free()', + '', + ].join('\n') + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + // The autoload marker component must exist and reference its script file. + const autoloadMarker = cg.getNodesByKind('component').find( + (n) => n.name === 'GameState' && n.decorators?.includes('autoload') + ); + expect(autoloadMarker).toBeDefined(); + + // _ready's calls must reach game_state.gd's reset() — but NOT queue_free() + // (an engine method that doesn't exist in the script stays unresolved). + const ready = cg + .getNodesByKind('method') + .find((n) => n.name === '_ready' && n.filePath!.endsWith('hud.gd')); + expect(ready).toBeDefined(); + const targets = cg + .getOutgoingEdges(ready!.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)); + const reset = targets.find((t) => t?.name === 'reset'); + expect(reset).toBeDefined(); + expect(reset?.filePath).toContain('game_state.gd'); + }); + + 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 resolve Erlang -behaviour refs only to module namespaces', () => { // On emqx, `-behaviour(supervisor)` (OTP behaviour, not in the repo) // fell through to bare-name matching and resolved to a @@ -96,10 +319,10 @@ describe('Resolution Module', () => { language: 'erlang', startLine: 61, endLine: 61, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; const behaviourModule: Node = { id: 'namespace:src/my_behaviour.erl:my_behaviour:1', kind: 'namespace', @@ -171,6 +394,7 @@ describe('Resolution Module', () => { }); expect(matchReference(appRef('ssl'), context)).toBeNull(); expect(matchReference(appRef('my_behaviour'), context)?.targetNodeId).toBe(behaviourModule.id); + }); it('should prefer same-module candidates over cross-module matches', () => { 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 eefb5d907..7dd5afd6f 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -76,6 +76,55 @@ 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; +}; + +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.filePath.endsWith('.tscn') && signature.includes('instance=ExtResource'); +} + const importESM = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise; @@ -1849,7 +1898,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(); @@ -1860,8 +1909,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); @@ -1928,7 +1981,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(); @@ -1939,7 +1992,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)) { @@ -2006,7 +2059,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(); @@ -2019,7 +2072,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/db/queries.ts b/src/db/queries.ts index 16b9d5f91..9f0d4075e 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1756,6 +1756,16 @@ export class QueryBuilder { return rows.map(rowToEdge); } + /** + * Get all edges with the given provenance (e.g. 'heuristic'). Used by + * synthesizers that re-read extraction-time heuristic wiring (scene-signal + * connections carrying {signal, method} metadata). + */ + getEdgesByProvenance(provenance: string): Edge[] { + const rows = this.db.prepare('SELECT * FROM edges WHERE provenance = ?').all(provenance) as EdgeRow[]; + return rows.map(rowToEdge); + } + /** * Find all edges where both source and target are in the given node set. * Useful for recovering inter-node connectivity after BFS. diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts new file mode 100644 index 000000000..ffacec3ff --- /dev/null +++ b/src/extraction/godot-resource-extractor.ts @@ -0,0 +1,415 @@ +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 uidToResourcePath = new Map(); + private nodesByScenePath = new Map(); + private uniqueNameToNode = new Map(); + private rootNode: Node | null = null; + private inAutoloadSection = false; + /** Scene node id → attached script res:// path (`script = ExtResource(...)`). */ + private scriptByNodeId = 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(); + 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; + this.inAutoloadSection = false; + + 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 (this.inAutoloadSection) { + this.extractAutoloadEntry(fileNodeId, line, lineNumber); + } else if (currentOwner) { + this.extractSectionProperty(currentOwner, line, lineNumber); + } + continue; + } + + const type = section[1]!; + const attrs = this.parseAttributes(section[2] ?? ''); + this.inAutoloadSection = type === 'autoload'; + 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 uid = attrs.get('uid'); + if (uid) this.uidToResourcePath.set(uid, 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); + } + + /** + * `[autoload]` entry in project.godot: `GameState="*res://core/game_state.gd"`. + * The singleton name is a bare global in every GDScript file, with no import + * to hang resolution on — emit a marker component (decorators: ['autoload']) + * whose signature carries the res:// path so the framework resolver can link + * receiver references to the script's class. The emitted reference also links + * the project file → script through the normal res:// file-path resolution. + */ + private extractAutoloadEntry(fileNodeId: string, line: string, lineNumber: number): void { + const match = line.match(/^\s*([A-Za-z_]\w*)\s*=\s*"([^"]+)"/); + if (!match) return; + const [, name, rawPath] = match; + const resPath = rawPath!.replace(/^\*/, ''); + if (!resPath.startsWith('res://')) return; + + const node = this.createNode('component', name!, `${this.filePath}::autoload:${name}`, lineNumber, 0, line.length); + node.signature = line.trim(); + node.decorators = ['autoload']; + this.addContains(fileNodeId, node.id); + this.addReference(fileNodeId, resPath, 'references', lineNumber, line.indexOf(resPath)); + } + + 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=')); + if (resourcePath.endsWith('.tscn')) { + this.edges.push({ + source: owner.id, + target: `file:${resourcePath.replace(/^res:\/\//, '')}`, + kind: 'extends', + line: lineNumber, + provenance: 'tree-sitter', + }); + } + } + + 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')); + // Remember which script drives this scene node — [connection] wiring + // needs it to find handler methods in another file. + this.scriptByNodeId.set(owner.id, resourcePath); + } + 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) { + 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); + return; + } + + const uniqueNameDecl = line.match(/^\s*unique_name_in_owner\s*=\s*true/); + if (uniqueNameDecl) { + this.uniqueNameToNode.set(owner.name, owner); + return; + } + + const uniqueRefRegex = /%\(?([A-Za-z_]\w*)\)?/g; + let refMatch; + while ((refMatch = uniqueRefRegex.exec(line)) !== null) { + this.addReference(owner.id, refMatch[1]!, 'references', lineNumber, refMatch.index); + } + } + + private extractConnection(fileNodeId: string, attrs: Map, line: string, lineNumber: number): void { + const method = attrs.get('method'); + if (!method) return; + + const fromStr = attrs.get('from') || '.'; + const toStr = attrs.get('to') || '.'; + const fromNode = fromStr.startsWith('%') + ? this.uniqueNameToNode.get(fromStr.slice(1)) ?? null + : this.resolveSceneNode(fromStr); + const toNode = toStr.startsWith('%') + ? this.uniqueNameToNode.get(toStr.slice(1)) ?? null + : this.resolveSceneNode(toStr); + 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, + // The handler usually lives in the script attached to the TO node — + // carry the path so the scene-connection synthesizer can bridge the + // flow across files without relying on name resolution. + scriptResPath: this.scriptByNodeId.get(toNode.id), + }, + }); + } + } + + 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)); + } + + const uidRefRegex = /uid:\/\/([a-z0-9]+)/g; + while ((match = uidRefRegex.exec(this.source)) !== null) { + const uid = match[1]; + if (!uid) continue; + const line = this.getLineNumber(match.index); + this.addReference(fileNodeId, uid, '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 d4127631d..484ebb3c5 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -11,7 +11,7 @@ import * as fsp from 'fs/promises'; 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 @@ -50,6 +50,8 @@ const WASM_GRAMMAR_FILES: Record = { terraform: 'tree-sitter-terraform.wasm', arkts: 'tree-sitter-arkts.wasm', nix: 'tree-sitter-nix.wasm', + + gdscript: 'tree-sitter-gdscript.wasm', }; /** @@ -123,6 +125,12 @@ export const EXTENSION_MAP: Record = { '.luau': 'luau', '.m': 'objc', '.mm': 'objc', + + // GDSCript / Godot engine files + '.gd': 'gdscript', + '.tscn': 'godot_resource', + '.tres': 'godot_resource', + '.godot': 'godot_resource', '.sol': 'solidity', // CFML: .cfc/.cfm parse with the tag-aware `cfml` grammar (custom CfmlExtractor // dialect-switches to cfscript for bare-script content); .cfs is pure CFScript. @@ -338,6 +346,9 @@ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ // kernel compiles the same-commit vendored C (codegraph-kernel/grammars/ // dart); crates.io tree-sitter-dart is a different-lineage fork (rejected). 'dart', + // GDScript: tree-sitter-gdscript v6.1.0 wasm (ABI 14), vendored by the + // Godot/GDScript support work — tree-sitter-wasms doesn't ship it. + 'gdscript', ]); /** Absolute path of a language's grammar WASM (vendored or tree-sitter-wasms). */ @@ -532,6 +543,7 @@ export function isLanguageSupported(language: Language): boolean { if (language === 'astro') return true; // custom extractor (frontmatter/script block delegation) if (language === 'liquid') return true; // custom regex extractor if (language === 'razor') return true; // custom RazorExtractor (.cshtml/.razor markup) + if (language === 'godot_resource') return true; // custom Godot scene/resource extractor if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver if (language === 'twig') return true; // file-level tracking only if (language === 'xml') return true; // MyBatis mapper extractor @@ -544,7 +556,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 === '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); @@ -567,7 +579,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', 'razor', 'godot_resource']; } /** @@ -641,6 +653,9 @@ export function getLanguageDisplayName(language: Language): string { lua: 'Lua', luau: 'Luau', objc: 'Objective-C', + + gdscript: 'GDScript', + godot_resource: 'Godot Resource', solidity: 'Solidity', nix: 'Nix', yaml: 'YAML', diff --git a/src/extraction/languages/gdscript.ts b/src/extraction/languages/gdscript.ts new file mode 100644 index 000000000..b44324a3a --- /dev/null +++ b/src/extraction/languages/gdscript.ts @@ -0,0 +1,895 @@ +import * as path from 'path'; +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types'; +import type { Node, UnresolvedReference } from '../../types'; + +// Node names follow the vendored ABI-14 grammar (tree-sitter-gdscript 6.1.0, +// PrestonKnopp) — see grammars.ts. This extractor replaces the former +// line-regex GDScriptExtractor: +// - symbols / scopes / signatures come from the AST (robust to nesting, +// lambdas, match bodies, strings containing code-like text) +// - the Godot-specific reference passes (node paths $/%, signal wiring, +// Callable targets, const-string propagation) are ported line-scans kept +// byte-compatible with the previous output contract — ~60 assertions in +// __tests__/extraction.test.ts pin those shapes. + +const KEYWORDS = new Set([ + 'if', 'elif', 'for', 'while', 'match', 'return', 'await', 'assert', + 'print', 'push_error', 'push_warning', 'preload', 'load', 'super', + 'func', 'signal', +]); + +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', +]); + +/** Per-file extraction state threaded through the recursive walk. */ +interface ExtractorState { + filePath: string; + /** PascalCase script class synthesized from `class_name X` or the filename. */ + scriptClass: Node | null; + /** Fallback owner when no script class exists. */ + fileId: string; + /** Const name → literal string value (`get_node(SOME_CONST)` propagation). */ + stringConstants: Map; + /** Owner id → (alias name → node path) for `var row := "A/%dB"` locals. */ + nodePathAliases: Map>; + /** Helper function name → parameter index used in a get_node lookup. */ + helperArgIndex: Map; + /** variable/constant nodes by start line (same-line ref attribution). */ + declarationByLine: Map; + /** function/method scopes for line-based owner resolution. */ + functionScopes: Array<{ id: string; indent: number; startLine: number }>; + /** Dedup guard for dynamic scene-node components. */ + dynamicNodeNames: Set; + /** Statement anchors by line, for synthesizing nodes during line passes. */ + statementByLine: Map; +} + +function 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; +} + +function 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; +} + +function 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; +} + +function 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; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?${}()|[\]\\]/g, '\\$&'); +} + +/** Last `/`-segment of a node path — receiver name inside member-call shapes. */ +function nodePathReceiverName(nodePath: string): string { + const cleaned = nodePath.replace(/^[$%]/, ''); + const lastSegment = cleaned.split('/').filter(Boolean).pop(); + return lastSegment || cleaned || nodePath; +} + +function pascalCaseFromFileName(filePath: string): string { + const base = path.basename(filePath, path.extname(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(filePath); +} + +function isSimpleNodeName(value: string): boolean { + return /^[A-Z_][A-Za-z0-9_]*$/.test(value); +} + +function isLikelyNodePath(value: string): boolean { + return /^[A-Z_][A-Za-z0-9_]*(?:\/[A-Z_][A-Za-z0-9_]*)*$/.test(value); +} + +/** `"CardReward%d"` → `"CardReward"` (only PascalCase-ish scene-path shapes). */ +function 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; +} + +function textOfField(node: SyntaxNode, field: string, source: string): string { + const child = getChildByField(node, field); + return child ? getNodeText(child, source) : ''; +} + +function firstIdentifierText(node: SyntaxNode, source: string): string { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'identifier') return getNodeText(child, source); + } + return ''; +} + +/** Annotation identifiers directly attached to a declaration ("onready", "export_range", …). */ +function annotationNames(node: SyntaxNode, source: string): string[] { + const names: string[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.type !== 'annotations') continue; + for (let j = 0; j < child.namedChildCount; j++) { + const ann = child.namedChild(j); + if (ann && ann.type === 'annotation') { + const ident = firstIdentifierText(ann, source); + if (ident) names.push(ident); + } + } + } + return names; +} + +/** Inner literal of a GDScript string-ish node: `"X"`, `'X'`, `&"X"`. */ +function stringLiteralText(valueNode: SyntaxNode, source: string): string { + const raw = getNodeText(valueNode, source).trim(); + const match = raw.match(/^&?["'](.*)["']$/s); + return match ? match[1]! : raw; +} + +export const gdscriptExtractor: LanguageExtractor = { + // Everything flows through the visitNode hook below: a GDScript script file + // is a virtual class wrapping `source`, and its Godot-specific semantics + // (node-path expressions, string-keyed signal/callable wiring) don't fit the + // generic declaration dispatch ladder. + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], // preload/load are plain calls — handled by the reference passes + callTypes: [], + variableTypes: [], + nameField: 'name', + bodyField: 'body', + paramsField: 'parameters', + + visitNode: (node, ctx) => { + if (node.type !== 'source' || node.parent !== null) return false; + + const state: ExtractorState = { + filePath: ctx.filePath, + scriptClass: null, + fileId: ctx.nodeStack[ctx.nodeStack.length - 1]!, + stringConstants: new Map(), + nodePathAliases: new Map(), + helperArgIndex: new Map(), + declarationByLine: new Map(), + functionScopes: [], + dynamicNodeNames: new Set(), + statementByLine: new Map(), + }; + extractFile(node, ctx, state); + return true; + }, +}; + +// --------------------------------------------------------------------------- +// AST walk — symbols +// --------------------------------------------------------------------------- + +function childrenOf(node: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) out.push(child); + } + return out; +} + +/** + * Root handler. Synthesizes the script-class wrapper (explicit + * `class_name X`, or an implicit PascalCase-of-filename class when the file + * starts with `extends`), walks members under it, then runs the ported + * reference passes over the raw source lines. + */ +function extractFile(root: SyntaxNode, ctx: ExtractorContext, state: ExtractorState): void { + const source = ctx.source; + const children = childrenOf(root); + + const classNameNode = children.find((c) => c.type === 'class_name_statement'); + const topLevelExtends = children.find((c) => c.type === 'extends_statement'); + const hasToolAnnotation = children.some( + (c) => c.type === 'annotation' && firstIdentifierText(c, source) === 'tool' + ); + + let createdClass: Node | null = null; + if (classNameNode) { + const name = textOfField(classNameNode, 'name', source); + createdClass = ctx.createNode('class', name, classNameNode); + } else if (topLevelExtends) { + const extendsTarget = textOfType(topLevelExtends, 'type', source); + createdClass = ctx.createNode('class', pascalCaseFromFileName(ctx.filePath), topLevelExtends); + if (createdClass) createdClass.signature = `implicit script class extends ${extendsTarget}`; + } + if (createdClass && hasToolAnnotation) createdClass.decorators = ['tool']; + state.scriptClass = createdClass; + + // Inline `@tool class_name X extends Y`: the extends_statement nests INSIDE + // class_name_statement — emit it here (the dispatch case swallows that node). + if (classNameNode) { + const inlineExtends = getChildByField(classNameNode, 'extends'); + if (inlineExtends && state.scriptClass) { + const target = textOfType(inlineExtends, 'type', source); + if (target) { + emitRef(ctx, state.scriptClass.id, target, 'extends', inlineExtends.startPosition.row + 1, inlineExtends.startPosition.column, state); + } + } + } + + ctx.pushScope(state.scriptClass?.id ?? state.fileId); + for (const child of children) { + dispatch(child, ctx, state); + } + runReferencePasses(ctx, state); + ctx.popScope(); +} + +function textOfType(node: SyntaxNode, type: string, source: string): string { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === type) return getNodeText(child, source); + const nested = textOfType(child, type, source); + if (nested) return nested; + } + return ''; +} + +function dispatch(node: SyntaxNode, ctx: ExtractorContext, state: ExtractorState): boolean { + recordStatementAnchor(node, state); + + switch (node.type) { + case 'class_name_statement': + case 'annotation': + return true; // handled at root scan / contextual reads + + case 'extends_statement': { + const target = textOfType(node, 'type', ctx.source); + if (target) { + emitRef(ctx, currentOwner(ctx), target, 'extends', node.startPosition.row + 1, node.startPosition.column); + } + return true; + } + + case 'signal_statement': { + const name = textOfField(node, 'name', ctx.source); + ctx.createNode('signal', name, node, { + signature: getNodeText(node, ctx.source).trim().replace(/\s+/g, ' '), + }); + return true; + } + + case 'enum_definition': { + const name = textOfField(node, 'name', ctx.source) || ''; + const enumNode = ctx.createNode('enum', name, node); + if (enumNode) { + ctx.pushScope(enumNode.id); + const body = getChildByField(node, 'body'); + if (body) { + for (const entry of childrenOf(body)) { + if (entry.type !== 'enumerator') continue; + ctx.createNode('enum_member', textOfField(entry, 'left', ctx.source), entry); + } + } + ctx.popScope(); + } + return true; + } + + case 'const_statement': + return extractVariableDeclaration(node, ctx, state, 'constant'); + + case 'variable_statement': + return extractVariableDeclaration(node, ctx, state, 'variable'); + + case 'function_definition': + case 'constructor_definition': // `func _init(...)` — Godot's constructor + return extractFunctionDefinition(node, ctx, state); + + case 'lambda': { + // Anonymous — no symbol node (parity with the old extractor); its body + // still contributes references attributed to the enclosing scope. + const body = getChildByField(node, 'body'); + if (body) dispatchChildren(body, ctx, state); + return true; + } + + case 'class_definition': { + const innerClass = ctx.createNode('class', textOfField(node, 'name', ctx.source), node); + if (!innerClass) return true; + // `class Inner extends Control:` — attribute the extends target to the + // PARENT scope (parity: the old extractor saw it as a plain line owned + // above the class scope). + const extendsChild = getChildByField(node, 'extends'); + if (extendsChild) { + const target = textOfType(extendsChild, 'type', ctx.source); + if (target) { + emitRef(ctx, parentOwner(ctx), target, 'extends', extendsChild.startPosition.row + 1, extendsChild.startPosition.column); + } + } + ctx.pushScope(innerClass.id); + const body = getChildByField(node, 'body'); + if (body) dispatchChildren(body, ctx, state); + ctx.popScope(); + return true; + } + + default: + // Unclaimed construct (expression wrappers, if/for/match bodies, …): + // signal "not handled" so dispatchChildren keeps descending — symbols + // can nest arbitrarily deep (locals inside control flow). + return false; + } +} + +function dispatchChildren(parent: SyntaxNode, ctx: ExtractorContext, state: ExtractorState): void { + for (const child of childrenOf(parent)) { + const handled = dispatch(child, ctx, state); + if (!handled) dispatchChildren(child, ctx, state); + } +} + +function recordStatementAnchor(node: SyntaxNode, state: ExtractorState): void { + const line = node.startPosition.row + 1; + if (!state.statementByLine.has(line)) state.statementByLine.set(line, node); +} + +function currentOwner(ctx: ExtractorContext): string { + return ctx.nodeStack[ctx.nodeStack.length - 1] ?? ctx.filePath; +} + +function parentOwner(ctx: ExtractorContext): string { + return ctx.nodeStack[ctx.nodeStack.length - 2] ?? currentOwner(ctx); +} + +function emitRef( + ctx: ExtractorContext, + fromNodeId: string, + referenceName: string, + referenceKind: UnresolvedReference['referenceKind'], + line: number, + column: number, + state?: ExtractorState +): void { + ctx.addUnresolvedReference({ + fromNodeId, + referenceName, + referenceKind, + line, + column, + ...(state ? { filePath: state.filePath, language: 'gdscript' as const } : {}), + }); +} + +function extractFunctionDefinition(node: SyntaxNode, ctx: ExtractorContext, state: ExtractorState): boolean { + const source = ctx.source; + // constructor_definition has no name field — the name is implied `_init`. + const name = node.type === 'constructor_definition' ? '_init' : textOfField(node, 'name', source); + const params = textOfField(node, 'parameters', source); + const returnType = textOfField(node, 'return_type', source); + const isStatic = node.namedChildren.some((c) => c.type === 'static_keyword'); + + // Parity with the old extractor: funcs in a script WITHOUT class_name or + // extends (no script class at all) extract as plain 'function' kind. + const parentId = currentOwner(ctx); + const parentNode = ctx.nodes.find((n) => n.id === parentId); + const kind: 'method' | 'function' = parentNode && parentNode.kind !== 'file' ? 'method' : 'function'; + + const method = ctx.createNode(kind, name, node, { + signature: `${params || '()'}${returnType ? ` -> ${returnType.trim()}` : ''}`, + isStatic, + }); + + registerNodeLookupHelper(node, ctx, state, name); + + if (method) { + ctx.pushScope(method.id); + const body = getChildByField(node, 'body'); + if (body) dispatchChildren(body, ctx, state); + ctx.popScope(); + } + return true; +} + +/** + * If this function performs a node lookup directly on one of its parameters + * (get_node(paramN)/find_child(paramN)/_find_node(x, paramN)), remember the + * parameter index so later calls to this helper resolve their argument through + * the same channel as literals (old `_find_node(root, "Name")` contract). + */ +function registerNodeLookupHelper(node: SyntaxNode, ctx: ExtractorContext, state: ExtractorState, functionName: string): void { + const source = ctx.source; + const paramsText = textOfField(node, 'parameters', source); + const paramNames = splitCallArguments(paramsText.replace(/^\s*\(|\)\s*$/g, '')) + .map((arg) => (arg.trim().match(/^([A-Za-z_]\w*)/) || [])[1]) + .filter((n): n is string => Boolean(n)); + if (paramNames.length === 0) return; + + const body = getChildByField(node, 'body'); + const bodyText = body ? getNodeText(body, source) : ''; + for (let paramIndex = 0; paramIndex < paramNames.length; paramIndex++) { + const escaped = escapeRegExp(paramNames[paramIndex]!); + const direct = new RegExp(`\\b(?:get_node|get_node_or_null|has_node|find_child)\\s*\\(\\s*${escaped}\\b`); + const projectHelper = new RegExp(`\\b_find_node\\s*\\([^,\\n]+,\\s*${escaped}\\b`); + if (direct.test(bodyText) || projectHelper.test(bodyText)) { + state.helperArgIndex.set(functionName, paramIndex); + break; + } + } +} + +/** + * Create a `variable`/`constant` symbol. Mirrors the old extractor: + * - signature = whitespace-collapsed statement text + * - first `@export*` annotation → decorators ['export'] / ['export_'] + * - constant string values populate the propagation table; `*_NAME` + * constants holding a simple scene-node name become dynamic `component`s + * - `@onready var x := $Path` emits a reference from the variable itself + */ +function extractVariableDeclaration( + node: SyntaxNode, + ctx: ExtractorContext, + state: ExtractorState, + kind: 'constant' | 'variable' +): boolean { + const source = ctx.source; + const name = textOfField(node, 'name', source); + const stmtText = getNodeText(node, source).trim().replace(/\s+/g, ' '); + + const varNode = ctx.createNode(kind, name, node, { signature: stmtText }); + if (!varNode) return true; + + state.declarationByLine.set(node.startPosition.row + 1, varNode); + + const annotations = annotationNames(node, source); + const exportAnn = annotations.find((a) => a.startsWith('export')); + if (exportAnn) { + varNode.decorators = [exportAnn]; + } + + if (kind === 'constant') { + const value = getChildByField(node, 'value'); + if (value && (value.type === 'string' || value.type === 'string_name')) { + const stringValue = stringLiteralText(value, source); + if (stringValue) { + state.stringConstants.set(name, stringValue); + if (/_NAME$/.test(name) && isSimpleNodeName(stringValue)) { + addDynamicNodeName(ctx, state, stringValue, node.startPosition.row + 1, stmtText, currentOwner(ctx)); + } + } + } + } + + if (annotations.includes('onready')) { + const onreadyPath = getNodeText(node, source).match(/[$]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/); + if (onreadyPath) { + emitRef(ctx, varNode.id, onreadyPath[1]!, 'references', node.startPosition.row + 1, node.startPosition.column, state); + } + } + + return true; +} + +function addDynamicNodeName( + ctx: ExtractorContext, + state: ExtractorState, + name: string, + lineNumber: number, + signature: string, + ownerId: string +): void { + if (state.dynamicNodeNames.has(name)) return; + state.dynamicNodeNames.add(name); + const anchor = + state.statementByLine.get(lineNumber) ?? + state.statementByLine.get(1) ?? // fallback: file head + undefined; + if (!anchor) return; + ctx.pushScope(ownerId); + ctx.createNode('component', name, anchor, { signature }); + ctx.popScope(); +} + +// --------------------------------------------------------------------------- +// Reference passes — ported line scans over the raw source +// --------------------------------------------------------------------------- + +function lookupAlias(state: ExtractorState, ownerId: string, name: string): string | undefined { + return state.nodePathAliases.get(ownerId)?.get(name) ?? state.stringConstants.get(name); +} + +function addNodePathAliasTo(state: ExtractorState, ownerId: string, alias: string, nodePath: string): void { + let aliases = state.nodePathAliases.get(ownerId); + if (!aliases) { + aliases = new Map(); + state.nodePathAliases.set(ownerId, aliases); + } + if (!aliases.has(alias)) aliases.set(alias, nodePath); +} + +function addNodePathReference( + ctx: ExtractorContext, + state: ExtractorState, + owner: string, + nodePath: string, + lineNumber: number, + column: number +): void { + const cleaned = nodePath.replace(/^[$%]/, ''); + emitRef(ctx, owner, nodePathReceiverName(cleaned), 'references', lineNumber, column, state); + if (cleaned.includes('/')) { + emitRef(ctx, owner, cleaned, 'references', lineNumber, column, state); + } + if (state.scriptClass && cleaned) { + emitRef(ctx, owner, `${state.scriptClass.name}/${cleaned}`, 'references', lineNumber, column, state); + } +} + +function addCallableTargetReferences( + ctx: ExtractorContext, + state: ExtractorState, + 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: RegExpExecArray | null; + while ((callableMatch = callableRegex.exec(args)) !== null) { + emitRef(ctx, owner, callableMatch[1]!, 'calls', lineNumber, argsColumn + callableMatch.index, state); + } + + 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') { + emitRef(ctx, owner, name, 'calls', lineNumber, argsColumn + args.indexOf(name), state); + } + } +} + +function extractStringNodePathAlias(state: ExtractorState, ownerId: string, code: string): void { + const stringAliasRegex = /\b(?:var|const)\s+([A-Za-z_]\w*)\s*(?::\s*[A-Za-z_]\w*)?\s*:=?\s*&?["']([^"']+)["']/g; + let match: RegExpExecArray | null; + while ((match = stringAliasRegex.exec(code)) !== null) { + const value = match[2]!; + if (isLikelyNodePath(value)) addNodePathAliasTo(state, ownerId, match[1]!, value); + } +} + +function resolveStringArgument(state: ExtractorState, ownerId: 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 lookupAlias(state, ownerId, identifier[1]!) ?? null; +} + +function extractNodePathReferences( + ctx: ExtractorContext, + state: ExtractorState, + owner: string, + code: string, + lineNumber: number, + functionOwner: string +): void { + extractStringNodePathAlias(state, functionOwner, code); + + let match: RegExpExecArray | null; + + const shorthandRegex = /[$%]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/g; + while ((match = shorthandRegex.exec(code)) !== null) { + addNodePathReference(ctx, state, owner, match[1]!, lineNumber, match.index); + } + + const getNodeRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']+)["']\s*\)/g; + while ((match = getNodeRegex.exec(code)) !== null) { + addNodePathReference(ctx, state, owner, match[1]!, lineNumber, match.index); + } + + const findChildRegex = /\bfind_child\s*\(\s*["']([^"']+)["']/g; + while ((match = findChildRegex.exec(code)) !== null) { + addNodePathReference(ctx, state, owner, match[1]!, lineNumber, match.index); + } + + const findChildAliasRegex = /\bfind_child\s*\(\s*([A-Za-z_]\w*)\b/g; + while ((match = findChildAliasRegex.exec(code)) !== null) { + const nodePath = lookupAlias(state, functionOwner, match[1]!); + if (nodePath) addNodePathReference(ctx, state, owner, nodePath, lineNumber, match.index); + } + + const projectFindNodeRegex = /\b_find_node\s*\(\s*[^,\n]+,\s*["']([^"']+)["']/g; + while ((match = projectFindNodeRegex.exec(code)) !== null) { + addNodePathReference(ctx, state, owner, match[1]!, lineNumber, match.index); + } + + const projectFindNodeAliasRegex = /\b_find_node\s*\(\s*[^,\n]+,\s*([A-Za-z_]\w*)\b/g; + while ((match = projectFindNodeAliasRegex.exec(code)) !== null) { + const nodePath = lookupAlias(state, functionOwner, match[1]!); + if (nodePath) addNodePathReference(ctx, state, owner, nodePath, lineNumber, match.index); + } + + const getNodeFormattedRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']*%d[^"']*)["']\s*%/g; + while ((match = getNodeFormattedRegex.exec(code)) !== null) { + const formattedNodePath = formattedNodePathBase(match[1]!); + if (formattedNodePath) { + addNodePathReference(ctx, state, owner, formattedNodePath, lineNumber, match.index); + } + } + + const formattedPathVariableRegex = /\b[A-Za-z_]\w*(?:_name|_path)\s*:=?\s*["']([^"']*%d[^"']*)["']\s*%/g; + while ((match = formattedPathVariableRegex.exec(code)) !== null) { + const formattedNodePath = formattedNodePathBase(match[1]!); + if (formattedNodePath) { + const rest = code.slice(match.index); + const variableName = (rest.match(/\b([A-Za-z_]\w*(?:_name|_path))\s*:=?/) || [])[1]; + if (variableName) addNodePathAliasTo(state, functionOwner, variableName, formattedNodePath); + addNodePathReference(ctx, state, owner, formattedNodePath, lineNumber, match.index); + } + } + + const getNodeConstantRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*([A-Za-z_]\w*)\s*\)/g; + while ((match = getNodeConstantRegex.exec(code)) !== null) { + const nodePath = lookupAlias(state, functionOwner, match[1]!); + if (nodePath) addNodePathReference(ctx, state, owner, nodePath, lineNumber, match.index); + } + + const helperCallRegex = /\b([A-Za-z_]\w*)\s*\(([^)]*)\)/g; + while ((match = helperCallRegex.exec(code)) !== null) { + const argumentIndex = state.helperArgIndex.get(match[1]!); + if (argumentIndex === undefined) continue; + const args = splitCallArguments(match[2]!); + const nodePath = resolveStringArgument(state, functionOwner, args[argumentIndex]); + if (nodePath) addNodePathReference(ctx, state, owner, nodePath, lineNumber, match.index); + } +} + +function extractSignalReferences( + ctx: ExtractorContext, + state: ExtractorState, + owner: string, + code: string, + lineNumber: number +): void { + let match: RegExpExecArray | null; + + 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; + while ((match = memberConnectRegex.exec(code)) !== null) { + const receiver = match[1] ?? nodePathReceiverName(match[2]!); + const signalName = match[3]!; + emitRef(ctx, owner, signalName, 'references', lineNumber, match.index, state); + emitRef(ctx, owner, `${receiver}.${signalName}`, 'references', lineNumber, match.index, state); + + const argsStart = memberConnectRegex.lastIndex; + const argsEnd = findCallEnd(code, argsStart - 1); + if (argsEnd > argsStart) { + addCallableTargetReferences(ctx, state, owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); + } + } + + const bareConnectRegex = /\b([A-Za-z_]\w*)\s*\.\s*connect\s*\(/g; + while ((match = bareConnectRegex.exec(code)) !== null) { + const signalName = match[1]!; + if (signalName === 'node') continue; + emitRef(ctx, owner, signalName, 'references', lineNumber, match.index, state); + + const argsStart = bareConnectRegex.lastIndex; + const argsEnd = findCallEnd(code, argsStart - 1); + if (argsEnd > argsStart) { + addCallableTargetReferences(ctx, state, owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); + } + } + + const legacyConnectRegex = /\bconnect\s*\(\s*(?:&)?["']([^"']+)["']\s*,/g; + while ((match = legacyConnectRegex.exec(code)) !== null) { + emitRef(ctx, owner, match[1]!, 'references', lineNumber, match.index, state); + + const argsStart = legacyConnectRegex.lastIndex; + const argsEnd = findCallEnd(code, code.indexOf('(', match.index)); + if (argsEnd > argsStart) { + addCallableTargetReferences(ctx, state, owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); + } + } + + const memberEmitRegex = /\b([A-Za-z_]\w*)\s*\.\s*emit\s*\(/g; + while ((match = memberEmitRegex.exec(code)) !== null) { + emitRef(ctx, owner, match[1]!, 'calls', lineNumber, match.index, state); + } + + const emitSignalRegex = /\bemit_signal\s*\(\s*(?:&)?["']([^"']+)["']/g; + while ((match = emitSignalRegex.exec(code)) !== null) { + emitRef(ctx, owner, match[1]!, 'calls', lineNumber, match.index, state); + } + + const callableRegex = /\bCallable\s*\(\s*(?:self|this|[A-Za-z_]\w*)\s*,\s*["']([A-Za-z_]\w*)["']\s*\)/g; + while ((match = callableRegex.exec(code)) !== null) { + emitRef(ctx, owner, match[1]!, 'calls', lineNumber, match.index, state); + } +} + +function functionScopesFor(ctx: ExtractorContext, state: ExtractorState): Array<{ id: string; indent: number; startLine: number }> { + if (state.functionScopes.length > 0) return state.functionScopes; + const lines = ctx.source.split('\n'); + for (const node of ctx.nodes) { + if ((node.kind === 'function' || node.kind === 'method') && node.language === 'gdscript' && node.filePath === state.filePath) { + state.functionScopes.push({ + id: node.id, + indent: indentOf(lines[node.startLine - 1] ?? ''), + startLine: node.startLine, + }); + } + } + state.functionScopes.sort((a, b) => a.startLine - b.startLine); + return state.functionScopes; +} + +function functionOwnerForLine(ctx: ExtractorContext, state: ExtractorState, line: number, indent: number): string { + let owner = state.scriptClass?.id ?? state.fileId; + for (const scope of functionScopesFor(ctx, state)) { + if (scope.startLine < line && scope.indent < indent) { + owner = scope.id; + } + } + return owner; +} + +function runReferencePasses(ctx: ExtractorContext, state: ExtractorState): void { + const lines = ctx.source.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const lineNumber = i + 1; + const rawLine = lines[i] ?? ''; + const code = stripComment(rawLine); + if (!code.trim()) continue; + + const indent = indentOf(rawLine); + const sameLineDecl = state.declarationByLine.get(lineNumber); + const owner = sameLineDecl ? sameLineDecl.id : functionOwnerForLine(ctx, state, lineNumber, indent); + + let match: RegExpExecArray | null; + + // NOTE: extends targets are emitted from the AST walk (extends_statement / + // class_definition children) — deliberately NOT matched here, or every + // extends would double-count (beehave parity-diff finding). + + const resourceRegex = /\b(?:preload|load)\s*\(\s*["']([^"']+)["']\s*\)/g; + while ((match = resourceRegex.exec(code)) !== null) { + emitRef(ctx, owner, match[1]!, 'references', lineNumber, match.index, state); + } + + const dynamicCallRegex = /\b(?:call|call_deferred)\s*\(\s*["']([A-Za-z_]\w*)["']/g; + while ((match = dynamicCallRegex.exec(code)) !== null) { + emitRef(ctx, owner, match[1]!, 'calls', lineNumber, match.index, state); + } + + // String-keyed dispatch family — has_method gates on the same names + // call()/Callable() invoke, so bridge them identically. + const hasMethodRegex = /\bhas_method\s*\(\s*["']([A-Za-z_]\w*)["']\s*\)/g; + while ((match = hasMethodRegex.exec(code)) !== null) { + emitRef(ctx, owner, match[1]!, 'calls', lineNumber, match.index, state); + } + + const groupRegex = /\b(?:add_to_group|remove_from_group)\s*\(\s*["']([^"']+)["']/g; + while ((match = groupRegex.exec(code)) !== null) { + emitRef(ctx, owner, match[1]!, 'references', lineNumber, match.index, state); + } + + const tweenPathRegex = /\b(?:tween_property|tween_method|tween_value)\s*\(\s*[^,]+,\s*["']([^"']+)["']/g; + while ((match = tweenPathRegex.exec(code)) !== null) { + emitRef(ctx, owner, match[1]!, 'references', lineNumber, match.index, state); + } + + const functionOwner = functionOwnerForLine(ctx, state, lineNumber, indent); + extractNodePathReferences(ctx, state, owner, code, lineNumber, functionOwner); + extractSignalReferences(ctx, state, owner, 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; + while ((match = memberCallRegex.exec(code)) !== null) { + const receiver = match[1] ?? nodePathReceiverName(match[2]!); + const method = match[3]!; + if (KEYWORDS.has(method)) continue; + emitRef(ctx, owner, `${receiver}.${method}`, 'calls', lineNumber, match.index, state); + } + + const callRegex = /\b([A-Za-z_]\w*)\s*\(/g; + while ((match = callRegex.exec(code)) !== null) { + const name = match[1]!; + const prefix = code.slice(Math.max(0, match.index - 8), match.index); + if ( + KEYWORDS.has(name) || + GODOT_BUILT_IN_CALLS.has(name) || + /\.\s*$/.test(prefix) || + /\bfunc\s+$/.test(prefix) || + /\bsignal\s+$/.test(prefix) + ) continue; + emitRef(ctx, owner, name, 'calls', lineNumber, match.index, state); + } + + // Dynamic scene-node declarations (old extractor ran these per-line too): + // `reward_button.name = "LootCardRewardButton"` and %d-formatted bases. + const dynamicNodeNameMatch = stripComment(rawLine).match(/\b[A-Za-z_]\w*\s*\.\s*name\s*=\s*["']([A-Za-z_]\w*)["']/); + if (dynamicNodeNameMatch) { + addDynamicNodeName(ctx, state, dynamicNodeNameMatch[1]!, lineNumber, code.trim(), functionOwnerForLine(ctx, state, lineNumber, indent)); + } + const formattedBase = extractFormattedNodePathBase(code); + if (formattedBase) { + addDynamicNodeName(ctx, state, formattedBase, lineNumber, code.trim(), functionOwnerForLine(ctx, state, lineNumber, indent)); + } + } +} + +/** Does this line establish a `%d`-formatted node-path base? (`"A/%dB" % i`) */ +function 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 formattedNodePathBase(formattedStringMatch[1]!); +} diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..0ed2586d5 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -27,6 +27,7 @@ import { luaExtractor } from './lua'; import { rExtractor } from './r'; import { luauExtractor } from './luau'; import { objcExtractor } from './objc'; +import { gdscriptExtractor } from './gdscript'; import { cfscriptExtractor } from './cfscript'; import { cfqueryExtractor } from './cfquery'; import { cobolExtractor } from './cobol'; @@ -60,6 +61,7 @@ export const EXTRACTORS: Partial> = { r: rExtractor, luau: luauExtractor, objc: objcExtractor, + gdscript: gdscriptExtractor, cfscript: cfscriptExtractor, cfquery: cfqueryExtractor, cobol: cobolExtractor, diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 05b13a9c1..3d957e625 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -28,6 +28,7 @@ import { SvelteExtractor } from './svelte-extractor'; import { AstroExtractor } from './astro-extractor'; import { DfmExtractor } from './dfm-extractor'; import { VueExtractor } from './vue-extractor'; +import { GodotResourceExtractor } from './godot-resource-extractor'; import { MyBatisExtractor } from './mybatis-extractor'; import { CfmlExtractor } from './cfml-extractor'; import { tryKernelExtract, takeDeferredPreParse } from './kernel'; @@ -6683,6 +6684,10 @@ export function extractFromSource( // Use custom extractor for ASP.NET Razor (.cshtml) / Blazor (.razor) markup const extractor = new RazorExtractor(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 === 'xml') { // Custom extractor for MyBatis mapper XML. Non-mapper XML returns just a // file node so the watcher tracks it without emitting symbols. diff --git a/src/extraction/wasm/tree-sitter-gdscript.wasm b/src/extraction/wasm/tree-sitter-gdscript.wasm new file mode 100755 index 000000000..491ff5e65 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-gdscript.wasm differ diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index 88f6f2e3f..5fb9854d6 100644 --- a/src/mcp/server-instructions.ts +++ b/src/mcp/server-instructions.ts @@ -53,6 +53,7 @@ calls; a grep/read exploration is dozens. - **"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. - **Need more?** Call \`codegraph_explore\` again with more specific names — treat the source it returns as already Read. +- **Godot projects**: \`res://...\` resource paths and scene node names are valid symbols for callers/callees/impact queries. ## Anti-patterns diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index b31c64fc7..3159f397f 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -87,11 +87,19 @@ const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']); * multi-thousand-character wall of source that bloats the agent's context. */ const CONTAINER_NODE_KINDS = new Set([ - 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module', + 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module', 'component', ]); +/** 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; } @@ -677,6 +685,35 @@ export const tools: ToolDefinition[] = [ }, annotations: READ_ONLY_ANNOTATIONS, }, + { + name: 'codegraph_references', + description: 'Find all symbols that reference . Inverse of codegraph_callers — answers "which scenes instance squad_base.tscn?" or "what connects to unit_died signal?" or "which scripts use EnemyManager?". Shows incoming edges by kind.', + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'Name of the symbol to find referrers for', + }, + file: { + type: 'string', + description: 'Narrow to definition in this file when several same-named symbols exist', + }, + kind: { + type: 'string', + description: 'Filter incoming edges by kind (calls, references, extends, imports, etc.)', + }, + limit: { + type: 'number', + description: 'Maximum number of referrers to return (default: 30)', + default: 30, + }, + projectPath: projectPathProperty, + }, + required: ['symbol'], + }, + annotations: READ_ONLY_ANNOTATIONS, + }, { name: 'codegraph_explore', description: 'PRIMARY TOOL — call FIRST for almost any question OR before an edit: how does X work, architecture, a bug, where/what is X, surveying an area, or the symbols you are about to change. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — treat the shown source as already Read; do NOT re-open those files), plus the call path among them. Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — more accurate context, in far fewer tokens and round-trips than a search/Read/Grep loop.', @@ -801,7 +838,7 @@ export function getStaticTools(): ToolDefinition[] { * status) remain fully functional — handlers stay, the library API and CLI are * untouched, and `CODEGRAPH_MCP_TOOLS=explore,node,...` re-enables any of them. */ -const DEFAULT_MCP_TOOLS = new Set(['explore']); +const DEFAULT_MCP_TOOLS = new Set(['explore', 'references']); /** * Tool handler that executes tools against a CodeGraph instance @@ -1397,6 +1434,7 @@ export class ToolHandler { // auto-banner wrapper to avoid duplicating its own pending-files section. if (toolName === 'codegraph_status') { return await this.handleStatus(args); + } // Read tools: off-load the CPU-heavy dispatch to the worker pool when one @@ -1480,6 +1518,7 @@ export class ToolHandler { case 'codegraph_callers': return await this.handleCallers(args); case 'codegraph_callees': return await this.handleCallees(args); case 'codegraph_impact': return await this.handleImpact(args); + case 'codegraph_references': return await this.handleReferences(args); case 'codegraph_explore': return await this.handleExplore(args); case 'codegraph_node': return await this.handleNode(args); case 'codegraph_files': return await this.handleFiles(args); @@ -1592,6 +1631,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); @@ -1639,6 +1682,81 @@ 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_references + */ + private async handleReferences(args: Record): Promise { + const symbol = this.validateString(args.symbol, 'symbol'); + if (typeof symbol !== 'string') return symbol; + + const cg = this.getCodeGraph(args.projectPath as string | undefined); + const limit = clamp((args.limit as number) || 30, 1, 100); + const fileFilter = typeof args.file === 'string' ? args.file : undefined; + const kindFilter = typeof args.kind === 'string' ? args.kind : undefined; + + const allMatches = this.findAllSymbols(cg, symbol); + if (allMatches.nodes.length === 0) { + return this.textResult(`Symbol "${symbol}" not found in the codebase`); + } + + const { groups } = this.groupDefinitions(allMatches.nodes, fileFilter); + + const collect = (defNodes: Node[]) => { + const seen = new Set(); + const referrers: { node: Node; edge: Edge }[] = []; + for (const node of defNodes) { + for (const e of cg.getIncomingEdges(node.id)) { + if (kindFilter && e.kind !== kindFilter && e.kind !== 'contains') continue; + if (e.kind === 'contains') continue; + const refNode = cg.getNode(e.source); + if (!refNode || seen.has(refNode.id)) continue; + seen.add(refNode.id); + referrers.push({ node: refNode, edge: e }); + } + } + return referrers; + }; + + if (groups.length === 1) { + const referrers = collect(groups[0]!); + if (referrers.length === 0) { + return this.textResult(`No incoming references found for "${symbol}"`); + } + const lines: string[] = [`Incoming references to **${symbol}**:\n`]; + for (const r of referrers.slice(0, limit)) { + const kind = r.edge.kind; + const label = ` - [${r.node.kind}] ${r.node.name} (${r.node.filePath}:${r.edge.line ?? '?'}) — via **${kind}**`; + lines.push(label); + } + if (referrers.length > limit) lines.push(`\n... and ${referrers.length - limit} more`); + return this.textResult(this.truncateOutput(lines.join('\n'))); + } + + // Multiple definitions + const lines: string[] = [`Incoming references to **${symbol}**:\n`]; + for (let gi = 0; gi < groups.length && gi < 5; gi++) { + const group = groups[gi]!; + const def = group[0]!; + const referrers = collect(group); + lines.push(`\n## ${def.qualifiedName}`); + for (const r of referrers.slice(0, Math.ceil(limit / groups.length))) { + const kind = r.edge.kind; + lines.push(` - [${r.node.kind}] ${r.node.name} (${r.node.filePath}:${r.edge.line ?? '?'}) — via **${kind}**`); + } + if (referrers.length > Math.ceil(limit / groups.length)) { + lines.push(` ... and ${referrers.length - Math.ceil(limit / groups.length)} more`); + } + } + return this.textResult(this.truncateOutput(lines.join('\n'))); + } + /** * Handle codegraph_callees */ @@ -4400,8 +4518,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; @@ -4516,21 +4638,23 @@ export class ToolHandler { return { nodes, note: '' }; } } - 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/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index b8bc1045a..809b4ed58 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -40,6 +40,9 @@ const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g; const SETSTATE_RE = /this\.setState\s*\(/; const FLUTTER_SETSTATE_RE = /\bsetState\s*\(/; // Flutter: setState((){…}) / this.setState const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g; +// Godot signal connect/emit: .connect("signal", Callable(self, "method")) or .connect("signal", "method") +const GODOT_CONNECT_RE = /\.connect\s*\(\s*['"]([^'"]+)['"]\s*,\s*(?:Callable\s*\(\s*(?:\w+|self)\s*,\s*['"](\w+)['"]\s*\)|['"](\w+)['"])/g; +const GODOT_EMIT_SIGNAL_RE = /emit_signal\s*\(\s*['"]([^'"]+)['"]/g; const MAX_JSX_CHILDREN = 30; // Vue SFC templates: kebab-case child components ( → ElButton) and // event bindings (@click="fn" / v-on:click="fn"). PascalCase children () @@ -339,7 +342,9 @@ async function eventEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P if (!content) continue; const hasEmit = content.includes('.emit(') || content.includes('.fire(') || content.includes('.dispatchEvent('); const hasOn = content.includes('.on(') || content.includes('.once(') || content.includes('.addListener('); - if (!hasEmit && !hasOn) continue; + const hasGodotConnect = content.includes('.connect('); + const hasGodotEmitSignal = content.includes('emit_signal('); + if (!hasEmit && !hasOn && !hasGodotConnect && !hasGodotEmitSignal) continue; const nodesInFile = ctx.getNodesInFile(file); const lineOf = makeLineAt(content, 1); @@ -365,6 +370,28 @@ async function eventEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P map.set(handler.id, `${file}:${lineOf(m.index)}`); handlersByEvent.set(m[1]!, map); } } + if (hasGodotEmitSignal) { + GODOT_EMIT_SIGNAL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = GODOT_EMIT_SIGNAL_RE.exec(content))) { + const disp = enclosingFn(nodesInFile, lineOf(m.index)); + if (!disp) continue; + const set = emitsByEvent.get(m[1]!) ?? new Set(); + set.add(disp.id); emitsByEvent.set(m[1]!, set); + } + } + if (hasGodotConnect) { + GODOT_CONNECT_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = GODOT_CONNECT_RE.exec(content))) { + const handlerName = m[2] || m[3]; + if (!handlerName) continue; + const handler = ctx.getNodesByName(handlerName).find((n) => n.kind === 'function' || n.kind === 'method'); + if (!handler) continue; + const map = handlersByEvent.get(m[1]!) ?? new Map(); + map.set(handler.id, `${file}:${lineOf(m.index)}`); handlersByEvent.set(m[1]!, map); + } + } } const edges: Edge[] = []; @@ -1932,9 +1959,105 @@ function goHandlerIdent(expr: string): string | null { return m ? m[1]! : null; } +/** + * Godot engine-invoked virtuals: `_ready()`, `_process(delta)`, + * `_unhandled_input(event)` and friends are called by the runtime, never by + * user code — so they have zero inbound `calls` edges in any static graph and + * every flow that routes through them dead-ends. Bridge owner class → virtual + * method so explore/trace can ENTER a flow through the script's hub node. + * Provenance: `heuristic`, `synthesizedBy: 'godot-engine-virtual'`. + */ +const GODOT_ENGINE_VIRTUALS = new Set([ + '_init', '_ready', '_enter_tree', '_exit_tree', '_process', + '_physics_process', '_input', '_shortcut_input', '_unhandled_input', + '_unhandled_key_input', '_draw', '_notification', +]); + +async function godotEngineVirtualEdges(queries: QueryBuilder): Promise { + const edges: Edge[] = []; + for (const cls of queries.getNodesByKind('class')) { + if (cls.language !== 'gdscript') continue; + const members = queries + .getOutgoingEdges(cls.id, ['contains']) + .map((e) => queries.getNodeById(e.target)) + .filter((n): n is Node => !!n && n.language === 'gdscript' && GODOT_ENGINE_VIRTUALS.has(n.name)); + for (const m of members) { + edges.push({ + source: cls.id, + target: m.id, + kind: 'calls', + line: m.startLine, + provenance: 'heuristic', + metadata: { synthesizedBy: 'godot-engine-virtual', via: m.name, registeredAt: `${m.filePath}:${m.startLine}` }, + }); + } + } + return edges; +} + +/** + * Godot scene-signal wiring, end-to-end. `.tscn` `[connection signal="pressed" + * from="X" to="Y" method="_on_pressed"]` emits (at extraction) a heuristic + * references edge scene-node → toNode carrying `{signal, method, scriptResPath}` + * metadata — but the handler METHOD usually lives in the script attached to + * `toNode`, a different file, so the flow stops at the scene boundary. Bridge + * it: source scene node → the script's class → same-name method. Only + * unambiguous candidates link; anything else stays silent (silent beats wrong). + */ +async function godotSceneConnectionEdges(queries: QueryBuilder): Promise { + const edges: Edge[] = []; + const seen = new Set(); + for (const e of queries.getEdgesByProvenance('heuristic')) { + const method = e.metadata?.method as string | undefined; + const scriptResPath = e.metadata?.scriptResPath as string | undefined; + if (!e.metadata?.signal || !method || !scriptResPath || !scriptResPath.startsWith('res://')) continue; + + // file node ids are `file:` — exactly what the + // res:// path holds after stripping the protocol. + const fileId = `file:${scriptResPath.replace(/^res:\/\//, '')}`; + const candidates: Node[] = []; + const walk = (nodeId: string, depth: number): void => { + if (depth > 4) return; + for (const contains of queries.getOutgoingEdges(nodeId, ['contains'])) { + const child = queries.getNodeById(contains.target); + if (!child) continue; + if ( + child.language === 'gdscript' && + child.name === method && + (child.kind === 'method' || child.kind === 'function') + ) { + candidates.push(child); + } + walk(child.id, depth + 1); + } + }; + walk(fileId, 0); + if (candidates.length !== 1) continue; // ambiguous or missing — stay silent + + const target = candidates[0]!; + const key = `${e.source}>${target.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: e.source, + target: target.id, + kind: 'calls', + line: target.startLine, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'godot-scene-connection', + via: e.metadata.signal, + registeredAt: `${target.filePath}:${target.startLine}`, + }, + }); + } + return edges; +} + async function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext, onYield: MaybeYield): Promise { let scanned255 = 0; let scannedFiles = 0; + // 1. Find the chain dispatcher(s): a Go method that invokes a `handlers` slice by index. const dispatchers: Node[] = []; for (const n of queries.iterateNodesByKind('method')) { @@ -3588,6 +3711,12 @@ export const SYNTH_PASSES: SynthPassDef[] = [ }, { name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) }, { name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) }, + // Godot engine-invoked virtuals (`_ready`, `_process`, …): no GDScript class + // members means the pass is provably empty. Scene-connection bridging reads + // heuristic wiring persisted by extraction; its edges merge with the rest of + // the batch below (all passes run before the single merged insert). + { name: 'godotVirtualEdges', gate: (has) => has('gdscript'), run: (q) => godotEngineVirtualEdges(q) }, + { name: 'godotSceneEdges', gate: (has) => has('gdscript'), run: (q) => godotSceneConnectionEdges(q) }, ]; /** Fixed non-registry steps: goMethodContains, goImplements, dedupe-merge, insertMergedEdges. */ @@ -3768,6 +3897,7 @@ export async function synthesizeCallbackEdges( const merged: Edge[] = []; const seen = new Set(); for (const e of passEdges.flat()) { + const key = `${e.source}>${e.target}`; if (seen.has(key)) continue; seen.add(key); diff --git a/src/resolution/frameworks/godot.ts b/src/resolution/frameworks/godot.ts new file mode 100644 index 000000000..9e15f0116 --- /dev/null +++ b/src/resolution/frameworks/godot.ts @@ -0,0 +1,191 @@ +import * as path from 'path'; +import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; + +export const godotResolver: FrameworkResolver = { + name: 'godot', + languages: ['gdscript', 'godot_resource'], + + detect(context: ResolutionContext): boolean { + return context.fileExists('project.godot') + || context.getAllFiles().some((f) => f.endsWith('.tscn') || f.endsWith('.gd')); + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const result = tryResolveResPath(ref, context); + if (result) return result; + + const autoload = tryResolveAutoload(ref, context); + if (autoload) return autoload; + + const result2 = tryResolveUniqueName(ref, context); + if (result2) return result2; + + const result3 = tryResolveGodotAlias(ref, context); + if (result3) return result3; + + const result4 = tryResolveUid(ref, context); + if (result4) return result4; + + const result5 = tryResolveSignal(ref, context); + if (result5) return result5; + + return null; + }, + + claimsReference(name: string): boolean { + return /^(BT|Limbo|BTAction|BTTask|BTDecorator|BTComposite|BTCondition)/.test(name); + }, +}; + +function tryResolveResPath(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (!ref.referenceName.startsWith('res://')) return null; + + const relativePath = ref.referenceName.replace(/^res:\/\//, ''); + const projectRoot = context.getProjectRoot(); + const fsPath = path.join(projectRoot, relativePath); + const normalized = path.normalize(fsPath); + + if (context.fileExists(normalized)) { + const nodes = context.getNodesInFile(normalized); + const fileNode = nodes.find((n) => n.kind === 'file'); + if (fileNode) { + return { + original: ref, + targetNodeId: fileNode.id, + confidence: 0.9, + resolvedBy: 'file-path', + }; + } + } + + return null; +} + +function tryResolveUniqueName(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const name = ref.referenceName.startsWith('%') ? ref.referenceName.slice(1) : ref.referenceName; + if (!name) return null; + + const target = context.getNodesByName(name).find( + (n) => n.kind === 'component' && n.language === 'godot_resource' + ); + if (target) { + return { + original: ref, + targetNodeId: target.id, + confidence: 0.85, + resolvedBy: 'framework', + }; + } + + return null; +} + +/** + * `[autoload]` singletons are bare globals in GDScript (`GameState.reset()`) + * with no import statement to resolve through. Bridge them: the marker + * component emitted from project.godot carries the script's res:// path in its + * signature — map receiver → that file's gdscript class, and a dotted + * `Name.method` reference straight onto the same-name method inside it. + */ +function tryResolveAutoload(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const dot = ref.referenceName.indexOf('.'); + const receiver = dot > 0 ? ref.referenceName.slice(0, dot) : ref.referenceName; + const methodName = dot > 0 ? ref.referenceName.slice(dot + 1) : null; + if (!receiver || (methodName !== null && !/^[A-Za-z_]\w*$/.test(methodName))) return null; + + for (const node of context.getNodesByName(receiver)) { + if (node.kind !== 'component' || node.language !== 'godot_resource') continue; + if (!node.decorators?.includes('autoload')) continue; + + const resMatch = node.signature?.match(/res:\/\/[^\s"]+\.gd/); + if (!resMatch) continue; + const fsPath = path.join(context.getProjectRoot(), resMatch[0].replace(/^res:\/\//, '')); + if (!context.fileExists(fsPath)) continue; + + const scriptNodes = context.getNodesInFile(fsPath).filter((n) => n.language === 'gdscript'); + const scriptClass = scriptNodes.find((n) => n.kind === 'class'); + if (!scriptClass) continue; + + if (methodName) { + const method = scriptNodes.find( + (n) => (n.kind === 'method' || n.kind === 'function') && n.name === methodName + ); + // Unknown method on the autoload script: stay silent rather than guess + // (silent beats wrong). + if (!method || method.id === ref.fromNodeId) continue; + return { + original: ref, + targetNodeId: method.id, + confidence: 0.85, + resolvedBy: 'framework', + }; + } + + if (scriptClass.id === ref.fromNodeId) continue; + return { + original: ref, + targetNodeId: scriptClass.id, + confidence: 0.85, + resolvedBy: 'framework', + }; + } + return null; +} + +function tryResolveSignal(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { const name = ref.referenceName; + if (!name || ref.referenceKind !== 'calls') return null; + + const target = context.getNodesByName(name).find( + (n) => n.kind === 'signal' && n.language === 'gdscript' + ); + if (target) { + return { + original: ref, + targetNodeId: target.id, + confidence: 0.85, + resolvedBy: 'framework', + }; + } + + return null; +} + +function tryResolveUid(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const uid = ref.referenceName; + if (!/^[a-z0-9]{16,}$/.test(uid)) return null; + + const nodes = context.getNodesByKind('import').filter( + (n) => n.language === 'godot_resource' + ); + for (const node of nodes) { + if (node.qualifiedName.includes(uid) || node.name.includes(uid)) { + return { + original: ref, + targetNodeId: node.id, + confidence: 0.85, + resolvedBy: 'framework', + }; + } + } + + return null; +} + +function tryResolveGodotAlias(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const name = ref.referenceName; + if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) return null; + + const target = context.getNodesByKind('class').find( + (n) => n.language === 'gdscript' && n.name === name + ); + if (target) { + return { + original: ref, + targetNodeId: target.id, + confidence: 0.8, + resolvedBy: 'framework', + }; + } + + return null; +} diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 91da9a01c..4951685cd 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -27,6 +27,7 @@ import { swiftObjcBridgeResolver } from './swift-objc'; import { reactNativeBridgeResolver } from './react-native'; import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; +import { godotResolver } from './godot'; import { cicsResolver } from './cics'; import { terraformResolver } from './terraform'; @@ -72,6 +73,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ expoModulesResolver, // React Native Fabric / Codegen view components — TS spec → component nodes fabricViewResolver, + // Godot — res:// path resolution, %UniqueName, class alias matching + godotResolver, // CICS pseudo-conversational TRANSID hops (COBOL) cicsResolver, // Terraform / OpenTofu — disambiguate var/local/module/resource refs to same-dir module diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2a1fe0d82..df549076f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -445,8 +445,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; } @@ -493,7 +493,7 @@ export function matchByQualifiedName( // Try partial qualified name match — again preferring the call site's own // file when more than one symbol's qualifiedName ends with the reference. - const parts = ref.referenceName.split(/[:.]/); + const parts = ref.referenceName.split(/[:.\/]/); const lastName = parts[parts.length - 1]; if (lastName) { const partialCandidates = keepForRef(context.getNodesByName(lastName)) diff --git a/src/types.ts b/src/types.ts index 5b0e407c5..8e2f73078 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export const NODE_KINDS = [ 'export', 'route', 'component', + 'signal', ] as const; export type NodeKind = (typeof NODE_KINDS)[number]; @@ -103,6 +104,8 @@ export const LANGUAGES = [ 'luau', 'objc', 'r', + 'gdscript', + 'godot_resource', 'solidity', 'nix', 'yaml',