From 776a55619769a1d9d641d3ff7c415de13ed1cf21 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 12:25:24 +0800 Subject: [PATCH 01/33] Add Godot language indexing support --- __tests__/extraction.test.ts | 68 +++++ __tests__/security.test.ts | 2 + src/extraction/gdscript-extractor.ts | 290 +++++++++++++++++++++ src/extraction/godot-resource-extractor.ts | 170 ++++++++++++ src/extraction/grammars.ts | 14 +- src/extraction/tree-sitter.ts | 10 + src/types.ts | 2 + 7 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 src/extraction/gdscript-extractor.ts create mode 100644 src/extraction/godot-resource-extractor.ts diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 927177599..f3cd63edf 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -93,6 +93,16 @@ describe('Language Detection', () => { expect(detectLanguage('main.dart')).toBe('dart'); }); + it('should detect GDScript files', () => { + expect(detectLanguage('player.gd')).toBe('gdscript'); + }); + + it('should detect Godot resource files', () => { + expect(detectLanguage('main.tscn')).toBe('godot_resource'); + expect(detectLanguage('card.tres')).toBe('godot_resource'); + expect(detectLanguage('project.godot')).toBe('godot_resource'); + }); + it('should return unknown for unsupported extensions', () => { expect(detectLanguage('styles.css')).toBe('unknown'); expect(detectLanguage('data.json')).toBe('unknown'); @@ -121,6 +131,64 @@ describe('Language Support', () => { expect(languages).toContain('swift'); expect(languages).toContain('kotlin'); expect(languages).toContain('dart'); + expect(languages).toContain('gdscript'); + expect(languages).toContain('godot_resource'); + }); +}); + +describe('GDScript Extraction', () => { + it('should extract GDScript classes, methods, variables, and references', () => { + const code = ` +extends Node +class_name PlayerController + +signal health_changed(value: int) +const MAX_HP := 100 +@onready var sprite := $Sprite2D + +func _ready() -> void: + var enemy = preload("res://enemy.gd") + setup_player() + +func setup_player() -> void: + health_changed.emit(MAX_HP) +`; + 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 === 'variable' && n.name === 'sprite')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'health_changed')).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 === 'setup_player')).toBe(true); + }); +}); + +describe('Godot Resource Extraction', () => { + it('should extract Godot scene nodes and external resource references', () => { + const code = ` +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://player_controller.gd" id="1_script"] + +[node name="Player" type="Node2D"] +script = ExtResource("1_script") + +[node name="Sprite2D" type="Sprite2D" parent="."] +`; + 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 === '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); }); }); diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 75ac84320..9547b3f17 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -375,6 +375,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/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts new file mode 100644 index 000000000..05c6adb56 --- /dev/null +++ b/src/extraction/gdscript-extractor.ts @@ -0,0 +1,290 @@ +import * as path from 'path'; +import { Edge, ExtractionError, ExtractionResult, Node, NodeKind, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; + +interface Scope { + id: string; + indent: number; + kind: NodeKind; +} + +interface FunctionScope extends Scope { + startLine: number; +} + +const KEYWORDS = new Set([ + 'if', + 'elif', + 'for', + 'while', + 'match', + 'return', + 'await', + 'assert', + 'print', + 'push_error', + 'push_warning', + 'preload', + 'load', + 'super', +]); + +/** + * Lightweight GDScript extractor. + * + * This intentionally avoids a hard dependency on a GDScript WASM grammar while + * still giving Godot projects useful symbol search and reference edges. + */ +export class GDScriptExtractor { + private filePath: string; + private source: string; + private lines: string[]; + private nodes: Node[] = []; + private edges: Edge[] = []; + private unresolvedReferences: UnresolvedReference[] = []; + private errors: ExtractionError[] = []; + + constructor(filePath: string, source: string) { + this.filePath = filePath; + this.source = source; + this.lines = source.split('\n'); + } + + extract(): ExtractionResult { + const startTime = Date.now(); + + try { + const fileNode = this.createFileNode(); + const scriptClass = this.extractScriptClass(fileNode); + this.extractDeclarations(fileNode, scriptClass); + this.extractReferences(fileNode, scriptClass); + } catch (error) { + this.errors.push({ + message: `GDScript extraction error: ${error instanceof Error ? error.message : String(error)}`, + filePath: this.filePath, + severity: 'error', + code: 'parse_error', + }); + } + + return { + nodes: this.nodes, + edges: this.edges, + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - startTime, + }; + } + + private createFileNode(): Node { + const node: Node = { + id: `file:${this.filePath}`, + kind: 'file', + name: path.basename(this.filePath), + qualifiedName: this.filePath, + filePath: this.filePath, + language: 'gdscript', + startLine: 1, + endLine: this.lines.length, + startColumn: 0, + endColumn: this.lines[this.lines.length - 1]?.length ?? 0, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + private extractScriptClass(fileNode: Node): Node | null { + const classNameMatch = this.source.match(/^\s*class_name\s+([A-Za-z_]\w*)/m); + if (!classNameMatch) return null; + + const index = classNameMatch.index ?? 0; + const line = this.getLineNumber(index); + const column = index - this.getLineStart(line) + classNameMatch[0].indexOf('class_name'); + const name = classNameMatch[1]!; + const node = this.createNode('class', name, `${this.filePath}::${name}`, line, column, line, column + classNameMatch[0].trimEnd().length); + this.addContains(fileNode.id, node.id); + return node; + } + + private extractDeclarations(fileNode: Node, scriptClass: Node | null): void { + const scopes: Scope[] = [{ id: scriptClass?.id ?? fileNode.id, indent: -1, kind: scriptClass ? 'class' : 'file' }]; + + for (let i = 0; i < this.lines.length; i++) { + const lineNumber = i + 1; + const rawLine = this.lines[i] ?? ''; + const code = this.stripComment(rawLine); + if (!code.trim()) continue; + + const indent = this.indentOf(rawLine); + while (scopes.length > 1 && indent <= scopes[scopes.length - 1]!.indent) { + scopes.pop(); + } + + const trimmed = code.trim(); + if (trimmed.startsWith('class_name ')) continue; + + const classMatch = trimmed.match(/^class\s+([A-Za-z_]\w*)\s*:?/); + if (classMatch) { + const node = this.createDeclarationNode('class', classMatch[1]!, rawLine, lineNumber, indent); + this.addContains(scopes[scopes.length - 1]!.id, node.id); + scopes.push({ id: node.id, indent, kind: 'class' }); + continue; + } + + const enumMatch = trimmed.match(/^enum(?:\s+([A-Za-z_]\w*))?/); + if (enumMatch) { + const name = enumMatch[1] || ''; + const node = this.createDeclarationNode('enum', name, rawLine, lineNumber, indent); + this.addContains(scopes[scopes.length - 1]!.id, node.id); + continue; + } + + const signalMatch = trimmed.match(/^signal\s+([A-Za-z_]\w*)/); + if (signalMatch) { + const node = this.createDeclarationNode('function', signalMatch[1]!, rawLine, lineNumber, indent); + node.signature = trimmed; + this.addContains(scopes[scopes.length - 1]!.id, node.id); + continue; + } + + const funcMatch = trimmed.match(/^(?:static\s+)?func\s+([A-Za-z_]\w*)\s*(\([^)]*\))?(?:\s*->\s*([^:]+))?/); + if (funcMatch) { + const insideClass = scopes.some((scope) => scope.kind === 'class'); + const node = this.createDeclarationNode(insideClass ? 'method' : 'function', funcMatch[1]!, rawLine, lineNumber, indent); + node.signature = `${funcMatch[2] || '()'}${funcMatch[3] ? ` -> ${funcMatch[3].trim()}` : ''}`; + node.isStatic = trimmed.startsWith('static '); + this.addContains(scopes[scopes.length - 1]!.id, node.id); + scopes.push({ id: node.id, indent, kind: node.kind }); + continue; + } + + const varMatch = trimmed.match(/^(?:@onready\s+)?(?:export\s+)?(var|const)\s+([A-Za-z_]\w*)/); + if (varMatch) { + const kind: NodeKind = varMatch[1] === 'const' ? 'constant' : 'variable'; + const node = this.createDeclarationNode(kind, varMatch[2]!, rawLine, lineNumber, indent); + node.signature = trimmed; + this.addContains(scopes[scopes.length - 1]!.id, node.id); + } + } + } + + private extractReferences(fileNode: Node, scriptClass: Node | null): void { + const functionScopes = this.nodes + .filter((node) => (node.kind === 'function' || node.kind === 'method') && node.language === 'gdscript') + .map((node) => ({ id: node.id, indent: this.indentOf(this.lines[node.startLine - 1] ?? ''), kind: node.kind, startLine: node.startLine } as FunctionScope)) + .sort((a, b) => a.startLine - b.startLine); + + const ownerForLine = (line: number, indent: number): string => { + let owner = scriptClass?.id ?? fileNode.id; + for (const scope of functionScopes) { + if (scope.startLine < line && scope.indent < indent) { + owner = scope.id; + } + } + return owner; + }; + + for (let i = 0; i < this.lines.length; i++) { + const lineNumber = i + 1; + const rawLine = this.lines[i] ?? ''; + const code = this.stripComment(rawLine); + const indent = this.indentOf(rawLine); + const owner = ownerForLine(lineNumber, indent); + + const extendsMatch = code.match(/^\s*extends\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\w.]*))/); + if (extendsMatch) { + this.addReference(owner, extendsMatch[1] || extendsMatch[2] || extendsMatch[3]!, 'extends', lineNumber, code.indexOf('extends')); + } + + const resourceRegex = /\b(?:preload|load)\s*\(\s*["']([^"']+)["']\s*\)/g; + let resourceMatch; + while ((resourceMatch = resourceRegex.exec(code)) !== null) { + this.addReference(owner, resourceMatch[1]!, 'references', lineNumber, resourceMatch.index); + } + + const callRegex = /\b([A-Za-z_]\w*)\s*\(/g; + let callMatch; + while ((callMatch = callRegex.exec(code)) !== null) { + const name = callMatch[1]!; + const prefix = code.slice(Math.max(0, callMatch.index - 8), callMatch.index); + if (KEYWORDS.has(name) || /\bfunc\s+$/.test(prefix) || /\bsignal\s+$/.test(prefix)) continue; + this.addReference(owner, name, 'calls', lineNumber, callMatch.index); + } + } + } + + private createDeclarationNode(kind: NodeKind, name: string, rawLine: string, line: number, indent: number): Node { + const column = rawLine.indexOf(name); + return this.createNode(kind, name, `${this.filePath}::${name}`, line, column < 0 ? indent : column, line, rawLine.length); + } + + private createNode(kind: NodeKind, name: string, qualifiedName: string, startLine: number, startColumn: number, endLine: number, endColumn: number): Node { + const node: Node = { + id: generateNodeId(this.filePath, kind, name, startLine), + kind, + name, + qualifiedName, + filePath: this.filePath, + language: 'gdscript', + startLine, + endLine, + startColumn, + endColumn, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + private addContains(source: string, target: string): void { + this.edges.push({ source, target, kind: 'contains' }); + } + + private addReference(fromNodeId: string, referenceName: string, referenceKind: UnresolvedReference['referenceKind'], line: number, column: number): void { + this.unresolvedReferences.push({ + fromNodeId, + referenceName, + referenceKind, + line, + column, + filePath: this.filePath, + language: 'gdscript', + }); + } + + private indentOf(line: string): number { + let indent = 0; + for (const char of line) { + if (char === ' ') indent += 1; + else if (char === '\t') indent += 4; + else break; + } + return indent; + } + + private stripComment(line: string): string { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const prev = line[i - 1]; + if (char === "'" && !inDouble && prev !== '\\') inSingle = !inSingle; + if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble; + if (char === '#' && !inSingle && !inDouble) return line.slice(0, i); + } + return line; + } + + private 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/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts new file mode 100644 index 000000000..7df892450 --- /dev/null +++ b/src/extraction/godot-resource-extractor.ts @@ -0,0 +1,170 @@ +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[] = []; + + 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 { + 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) continue; + + const type = section[1]!; + const attrs = this.parseAttributes(section[2] ?? ''); + if (type === 'node') { + const name = attrs.get('name') || ''; + const nodeType = attrs.get('type'); + const node = this.createNode('component', name, `${this.filePath}::node:${name}`, lineNumber, 0, line.length); + node.signature = nodeType ? `[node name="${name}" type="${nodeType}"]` : line.trim(); + this.addContains(fileNodeId, node.id); + } else if (type === 'ext_resource') { + const resourcePath = attrs.get('path'); + if (!resourcePath) continue; + 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)); + } 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); + } + } + + this.extractInlineResourcePaths(fileNodeId); + } + + private extractInlineResourcePaths(fileNodeId: string): void { + const pathRegex = /["'](res:\/\/[^"']+)["']/g; + let match; + while ((match = pathRegex.exec(this.source)) !== null) { + const resourcePath = match[1]; + if (!resourcePath) continue; + const line = this.getLineNumber(match.index); + this.addReference(fileNodeId, resourcePath, 'references', line, match.index - this.getLineStart(line)); + } + } + + private parseAttributes(text: string): Map { + const attrs = new Map(); + const attrRegex = /([A-Za-z_]\w*)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g; + let match; + while ((match = attrRegex.exec(text)) !== null) { + attrs.set(match[1]!, match[2] ?? match[3] ?? match[4] ?? ''); + } + return attrs; + } + + private createNode(kind: Node['kind'], name: string, qualifiedName: string, line: number, startColumn: number, endColumn: number): Node { + const node: Node = { + id: generateNodeId(this.filePath, kind, name, line), + kind, + name, + qualifiedName, + filePath: this.filePath, + language: 'godot_resource', + startLine: line, + endLine: line, + startColumn, + endColumn, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + private addContains(source: string, target: string): void { + this.edges.push({ source, target, kind: 'contains' }); + } + + private addReference(fromNodeId: string, referenceName: string, referenceKind: UnresolvedReference['referenceKind'], line: number, column: number): void { + const key = `${fromNodeId}:${referenceKind}:${referenceName}`; + if (this.referenceKeys.has(key)) return; + this.referenceKeys.add(key); + + this.unresolvedReferences.push({ + fromNodeId, + referenceName, + referenceKind, + line, + column, + filePath: this.filePath, + language: 'godot_resource', + }); + } + + private 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 c78c52ce7..504342af4 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import { Parser, Language as WasmLanguage } from 'web-tree-sitter'; import { Language } from '../types'; -export type GrammarLanguage = Exclude; +export type GrammarLanguage = Exclude; /** * WASM filename map — maps each language to its .wasm grammar file @@ -92,6 +92,10 @@ export const EXTENSION_MAP: Record = { '.sc': 'scala', '.lua': 'lua', '.luau': 'luau', + '.gd': 'gdscript', + '.tscn': 'godot_resource', + '.tres': 'godot_resource', + '.godot': 'godot_resource', }; /** @@ -236,6 +240,8 @@ export function isLanguageSupported(language: Language): boolean { if (language === 'svelte') return true; // custom extractor (script block delegation) if (language === 'vue') return true; // custom extractor (script block delegation) if (language === 'liquid') return true; // custom regex extractor + if (language === 'gdscript') return true; // custom Godot/GDScript extractor + if (language === 'godot_resource') return true; // custom Godot scene/resource extractor if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver if (language === 'twig') return true; // file-level tracking only if (language === 'unknown') return false; @@ -246,7 +252,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 === 'liquid') return true; + if (language === 'svelte' || language === 'vue' || language === 'liquid' || language === 'gdscript' || language === 'godot_resource') return true; if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed return languageCache.has(language); } @@ -255,7 +261,7 @@ export function isGrammarLoaded(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', 'liquid']; + return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'vue', 'liquid', 'gdscript', 'godot_resource']; } /** @@ -325,6 +331,8 @@ export function getLanguageDisplayName(language: Language): string { scala: 'Scala', lua: 'Lua', luau: 'Luau', + gdscript: 'GDScript', + godot_resource: 'Godot Resource', yaml: 'YAML', twig: 'Twig', unknown: 'Unknown', diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 280224090..e4e9df780 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -23,6 +23,8 @@ import { LiquidExtractor } from './liquid-extractor'; import { SvelteExtractor } from './svelte-extractor'; import { DfmExtractor } from './dfm-extractor'; import { VueExtractor } from './vue-extractor'; +import { GDScriptExtractor } from './gdscript-extractor'; +import { GodotResourceExtractor } from './godot-resource-extractor'; import { getAllFrameworkResolvers, getApplicableFrameworks, @@ -2535,6 +2537,14 @@ export function extractFromSource( // Use custom extractor for Liquid const extractor = new LiquidExtractor(filePath, source); result = extractor.extract(); + } else if (detectedLanguage === 'gdscript') { + // Use custom extractor for GDScript + const extractor = new GDScriptExtractor(filePath, source); + result = extractor.extract(); + } else if (detectedLanguage === 'godot_resource') { + // Use custom extractor for Godot text scenes/resources + const extractor = new GodotResourceExtractor(filePath, source); + result = extractor.extract(); } else if (detectedLanguage === 'yaml' || detectedLanguage === 'twig') { // No symbol extraction — file is tracked at the file-record level only. // Framework extractors (e.g. Drupal routing resolver) run below and may diff --git a/src/types.ts b/src/types.ts index 0168665d2..1e07bca03 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,8 @@ export const LANGUAGES = [ 'scala', 'lua', 'luau', + 'gdscript', + 'godot_resource', 'yaml', 'twig', 'unknown', From 57b7432e61cda0024128c13f0c0495caa74c3787 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 12:35:51 +0800 Subject: [PATCH 02/33] Improve GDScript annotation extraction --- __tests__/extraction.test.ts | 27 +++++++++++++++++++++++++++ src/extraction/gdscript-extractor.ts | 18 ++++++++++-------- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index f3cd63edf..c5e76a131 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -145,6 +145,8 @@ class_name PlayerController signal health_changed(value: int) const MAX_HP := 100 @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") @@ -163,12 +165,37 @@ func setup_player() -> void: 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 === 'variable' && n.name === 'sprite')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'move_ratio')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'shared_counter')).toBe(true); expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'health_changed')).toBe(true); expect(result.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 === '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); + }); }); describe('Godot Resource Extraction', () => { diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 05c6adb56..420955af9 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -29,6 +29,8 @@ const KEYWORDS = new Set([ 'super', ]); +const ANNOTATION_PREFIX = '(?:(?:@\\w+(?:\\([^)]*\\))?)\\s+)*'; + /** * Lightweight GDScript extractor. * @@ -95,12 +97,12 @@ export class GDScriptExtractor { } private extractScriptClass(fileNode: Node): Node | null { - const classNameMatch = this.source.match(/^\s*class_name\s+([A-Za-z_]\w*)/m); + const classNameMatch = this.source.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}class_name\\s+([A-Za-z_]\\w*)`, 'm')); if (!classNameMatch) return null; const index = classNameMatch.index ?? 0; const line = this.getLineNumber(index); - const column = index - this.getLineStart(line) + classNameMatch[0].indexOf('class_name'); + const column = index - this.getLineStart(line) + classNameMatch[0].indexOf(classNameMatch[1]!); const name = classNameMatch[1]!; const node = this.createNode('class', name, `${this.filePath}::${name}`, line, column, line, column + classNameMatch[0].trimEnd().length); this.addContains(fileNode.id, node.id); @@ -122,9 +124,9 @@ export class GDScriptExtractor { } const trimmed = code.trim(); - if (trimmed.startsWith('class_name ')) continue; + if (new RegExp(`^${ANNOTATION_PREFIX}class_name\\s+`).test(trimmed)) continue; - const classMatch = trimmed.match(/^class\s+([A-Za-z_]\w*)\s*:?/); + const classMatch = trimmed.match(new RegExp(`^${ANNOTATION_PREFIX}class\\s+([A-Za-z_]\\w*)\\s*(?:extends\\s+[^:]+)?\\s*:?`)); if (classMatch) { const node = this.createDeclarationNode('class', classMatch[1]!, rawLine, lineNumber, indent); this.addContains(scopes[scopes.length - 1]!.id, node.id); @@ -148,18 +150,18 @@ export class GDScriptExtractor { continue; } - const funcMatch = trimmed.match(/^(?:static\s+)?func\s+([A-Za-z_]\w*)\s*(\([^)]*\))?(?:\s*->\s*([^:]+))?/); + const funcMatch = trimmed.match(new RegExp(`^${ANNOTATION_PREFIX}(?:static\\s+)?func\\s+([A-Za-z_]\\w*)\\s*(\\([^)]*\\))?(?:\\s*->\\s*([^:]+))?`)); if (funcMatch) { const insideClass = scopes.some((scope) => scope.kind === 'class'); const node = this.createDeclarationNode(insideClass ? 'method' : 'function', funcMatch[1]!, rawLine, lineNumber, indent); node.signature = `${funcMatch[2] || '()'}${funcMatch[3] ? ` -> ${funcMatch[3].trim()}` : ''}`; - node.isStatic = trimmed.startsWith('static '); + node.isStatic = /\bstatic\s+func\b/.test(trimmed); this.addContains(scopes[scopes.length - 1]!.id, node.id); scopes.push({ id: node.id, indent, kind: node.kind }); continue; } - const varMatch = trimmed.match(/^(?:@onready\s+)?(?:export\s+)?(var|const)\s+([A-Za-z_]\w*)/); + const varMatch = trimmed.match(new RegExp(`^${ANNOTATION_PREFIX}(?:static\\s+)?(var|const)\\s+([A-Za-z_]\\w*)`)); if (varMatch) { const kind: NodeKind = varMatch[1] === 'const' ? 'constant' : 'variable'; const node = this.createDeclarationNode(kind, varMatch[2]!, rawLine, lineNumber, indent); @@ -192,7 +194,7 @@ export class GDScriptExtractor { const indent = this.indentOf(rawLine); const owner = ownerForLine(lineNumber, indent); - const extendsMatch = code.match(/^\s*extends\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\w.]*))/); + const extendsMatch = code.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}(?:(?:class_name|class)\\s+[A-Za-z_]\\w*\\s+)?extends\\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\\w.]*))`)); if (extendsMatch) { this.addReference(owner, extendsMatch[1] || extendsMatch[2] || extendsMatch[3]!, 'extends', lineNumber, code.indexOf('extends')); } From 8fc3df87bc517eb2a11ed0b76d8e747813616d70 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 12:48:28 +0800 Subject: [PATCH 03/33] Improve Godot scene and script graph extraction --- __tests__/extraction.test.ts | 22 +++++ src/extraction/gdscript-extractor.ts | 23 ++++- src/extraction/godot-resource-extractor.ts | 100 ++++++++++++++++++++- 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index c5e76a131..720eb775e 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -196,6 +196,24 @@ class InnerPanel extends Control: expect(result.unresolvedReferences.some((r) => r.referenceKind === 'extends' && r.referenceName === 'Control')).toBe(true); expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'emit_changed')).toBe(true); }); + + it('should create an implicit script class for extends-only GDScript files', () => { + const code = ` +extends Control + +func _ready() -> void: + setup() + +func setup() -> void: + pass +`; + const result = extractFromSource('battle_hud.gd', code); + + expect(result.nodes.some((n) => n.kind === 'class' && n.name === 'BattleHud')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.name === '_ready')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'setup')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'extends' && r.referenceName === 'Control')).toBe(true); + }); }); describe('Godot Resource Extraction', () => { @@ -209,6 +227,8 @@ describe('Godot Resource Extraction', () => { script = ExtResource("1_script") [node name="Sprite2D" type="Sprite2D" parent="."] + +[connection signal="pressed" from="Sprite2D" to="." method="_on_sprite_pressed"] `; const result = extractFromSource('player.tscn', code); @@ -216,6 +236,8 @@ script = ExtResource("1_script") expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'Sprite2D')).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 === 'calls' && r.referenceName === '_on_sprite_pressed')).toBe(true); + expect(result.edges.some((e) => e.kind === 'references' && e.metadata?.method === '_on_sprite_pressed')).toBe(true); }); }); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 420955af9..cc790e00e 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -57,7 +57,7 @@ export class GDScriptExtractor { try { const fileNode = this.createFileNode(); - const scriptClass = this.extractScriptClass(fileNode); + const scriptClass = this.extractScriptClass(fileNode) ?? this.extractImplicitScriptClass(fileNode); this.extractDeclarations(fileNode, scriptClass); this.extractReferences(fileNode, scriptClass); } catch (error) { @@ -109,6 +109,20 @@ export class GDScriptExtractor { return node; } + private extractImplicitScriptClass(fileNode: Node): Node | null { + const extendsMatch = this.source.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}extends\\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\\w.]*))`, 'm')); + if (!extendsMatch) return null; + + const index = extendsMatch.index ?? 0; + const line = this.getLineNumber(index); + const name = this.scriptClassNameFromPath(); + const column = index - this.getLineStart(line); + const node = this.createNode('class', name, `${this.filePath}::${name}`, line, column, line, column + (this.lines[line - 1]?.trimEnd().length ?? 0)); + node.signature = `implicit script class extends ${extendsMatch[1] || extendsMatch[2] || extendsMatch[3]}`; + this.addContains(fileNode.id, node.id); + return node; + } + private extractDeclarations(fileNode: Node, scriptClass: Node | null): void { const scopes: Scope[] = [{ id: scriptClass?.id ?? fileNode.id, indent: -1, kind: scriptClass ? 'class' : 'file' }]; @@ -289,4 +303,11 @@ export class GDScriptExtractor { } return pos; } + + private scriptClassNameFromPath(): string { + const base = path.basename(this.filePath, path.extname(this.filePath)); + const words = base.split(/[^A-Za-z0-9]+/).filter(Boolean); + const pascal = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(''); + return pascal || path.basename(this.filePath); + } } diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts index 7df892450..10f7cc2e8 100644 --- a/src/extraction/godot-resource-extractor.ts +++ b/src/extraction/godot-resource-extractor.ts @@ -14,6 +14,9 @@ export class GodotResourceExtractor { private unresolvedReferences: UnresolvedReference[] = []; private referenceKeys = new Set(); private errors: ExtractionError[] = []; + private extResources = new Map(); + private nodesByScenePath = new Map(); + private rootNode: Node | null = null; constructor(filePath: string, source: string) { this.filePath = filePath; @@ -64,39 +67,130 @@ export class GodotResourceExtractor { } private extractSections(fileNodeId: string): void { + let currentNode: Node | null = null; + for (let i = 0; i < this.lines.length; i++) { const line = this.lines[i] ?? ''; const lineNumber = i + 1; const section = line.match(/^\[([A-Za-z_]+)([^\]]*)\]/); - if (!section) continue; + if (!section) { + if (currentNode) this.extractNodeProperty(currentNode, line, lineNumber); + continue; + } const type = section[1]!; const attrs = this.parseAttributes(section[2] ?? ''); if (type === 'node') { const name = attrs.get('name') || ''; const nodeType = attrs.get('type'); - const node = this.createNode('component', name, `${this.filePath}::node:${name}`, lineNumber, 0, line.length); + 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(); - this.addContains(fileNodeId, node.id); + if (!attrs.has('parent') && !this.rootNode) this.rootNode = node; + this.nodesByScenePath.set(scenePath, node); + this.addNodeContainment(fileNodeId, node, attrs.get('parent')); + currentNode = node; } else if (type === 'ext_resource') { const resourcePath = attrs.get('path'); + const id = attrs.get('id'); if (!resourcePath) continue; + if (id) this.extResources.set(id, resourcePath); const node = this.createNode('import', resourcePath, `${this.filePath}::ext_resource:${resourcePath}`, lineNumber, 0, line.length); node.signature = line.trim(); this.addContains(fileNodeId, node.id); this.addReference(fileNodeId, resourcePath, 'references', lineNumber, line.indexOf(resourcePath)); + currentNode = 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); + currentNode = null; + } else if (type === 'connection') { + this.extractConnection(fileNodeId, attrs, line, lineNumber); + currentNode = null; + } else { + currentNode = null; } } this.extractInlineResourcePaths(fileNodeId); } + private extractNodeProperty(node: Node, line: string, lineNumber: number): void { + const scriptMatch = line.match(/^\s*script\s*=\s*ExtResource\("([^"]+)"\)/); + if (!scriptMatch) return; + + const resourcePath = this.extResources.get(scriptMatch[1]!); + if (!resourcePath) return; + + this.addReference(node.id, resourcePath, 'references', lineNumber, line.indexOf('ExtResource')); + } + + private extractConnection(fileNodeId: string, attrs: Map, line: string, lineNumber: number): void { + const method = attrs.get('method'); + if (!method) return; + + const fromNode = this.resolveSceneNode(attrs.get('from') || '.'); + const toNode = this.resolveSceneNode(attrs.get('to') || '.'); + const ownerId = fromNode?.id ?? fileNodeId; + this.addReference(ownerId, method, 'calls', lineNumber, line.indexOf(method)); + + if (toNode) { + this.edges.push({ + source: ownerId, + target: toNode.id, + kind: 'references', + line: lineNumber, + column: line.indexOf('to='), + provenance: 'heuristic', + metadata: { + signal: attrs.get('signal'), + method, + }, + }); + } + } + + private addNodeContainment(fileNodeId: string, node: Node, parent: string | undefined): void { + if (!parent) { + this.addContains(fileNodeId, node.id); + return; + } + + const parentPath = this.normalizeScenePath(parent || '.'); + const parentNode = parentPath === '.' ? this.rootNode : this.nodesByScenePath.get(parentPath); + 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; From 16937ba058b1ab8bf9d7625238c5570b87054bca Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 12:55:24 +0800 Subject: [PATCH 04/33] Improve Godot reference and resource extraction --- __tests__/extraction.test.ts | 27 ++++++++++ __tests__/resolution.test.ts | 43 +++++++++++++++ src/extraction/gdscript-extractor.ts | 62 +++++++++++++++++++++- src/extraction/godot-resource-extractor.ts | 44 ++++++++++----- src/resolution/name-matcher.ts | 14 +++-- 5 files changed, 173 insertions(+), 17 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 720eb775e..7649df951 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -150,6 +150,9 @@ static var shared_counter := 0 func _ready() -> void: var enemy = preload("res://enemy.gd") + var tint = Color(1, 0, 0) + $Sprite2D.play() + %StatusPanel.refresh() setup_player() func setup_player() -> void: @@ -171,6 +174,10 @@ func setup_player() -> void: 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 === 'Color')).toBe(false); expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls' && r.referenceName === 'setup_player')).toBe(true); }); @@ -239,6 +246,26 @@ script = ExtResource("1_script") 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 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); + }); }); describe('TypeScript Extraction', () => { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 1ca3a3f82..f140b0afc 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -82,6 +82,49 @@ 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 prefer same-module candidates over cross-module matches', () => { // Simulates a Python monorepo where multiple apps define navigate() const candidateA: Node = { diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index cc790e00e..351345f59 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -27,10 +27,49 @@ const KEYWORDS = new Set([ 'preload', 'load', 'super', + 'func', + 'signal', ]); const ANNOTATION_PREFIX = '(?:(?:@\\w+(?:\\([^)]*\\))?)\\s+)*'; +const GODOT_BUILT_IN_CALLS = new Set([ + 'AABB', + 'Array', + 'Basis', + 'Callable', + 'Color', + 'Dictionary', + 'NodePath', + 'PackedByteArray', + 'PackedColorArray', + 'PackedFloat32Array', + 'PackedFloat64Array', + 'PackedInt32Array', + 'PackedInt64Array', + 'PackedScene', + 'PackedStringArray', + 'PackedVector2Array', + 'PackedVector3Array', + 'Plane', + 'Projection', + 'Quaternion', + 'Rect2', + 'Rect2i', + 'RID', + 'Signal', + 'String', + 'StringName', + 'Transform2D', + 'Transform3D', + 'Vector2', + 'Vector2i', + 'Vector3', + 'Vector3i', + 'Vector4', + 'Vector4i', +]); + /** * Lightweight GDScript extractor. * @@ -219,12 +258,27 @@ export class GDScriptExtractor { this.addReference(owner, resourceMatch[1]!, 'references', lineNumber, resourceMatch.index); } + const memberCallRegex = /(?:\b([A-Za-z_]\w*)|([$%][A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*))\s*\.\s*([A-Za-z_]\w*)\s*\(/g; + let memberCallMatch; + while ((memberCallMatch = memberCallRegex.exec(code)) !== null) { + const receiver = memberCallMatch[1] || this.nodePathReceiverName(memberCallMatch[2]!); + const method = memberCallMatch[3]!; + if (KEYWORDS.has(method)) continue; + this.addReference(owner, `${receiver}.${method}`, 'calls', lineNumber, memberCallMatch.index); + } + const callRegex = /\b([A-Za-z_]\w*)\s*\(/g; let callMatch; while ((callMatch = callRegex.exec(code)) !== null) { const name = callMatch[1]!; const prefix = code.slice(Math.max(0, callMatch.index - 8), callMatch.index); - if (KEYWORDS.has(name) || /\bfunc\s+$/.test(prefix) || /\bsignal\s+$/.test(prefix)) continue; + if ( + KEYWORDS.has(name) || + GODOT_BUILT_IN_CALLS.has(name) || + /\.\s*$/.test(prefix) || + /\bfunc\s+$/.test(prefix) || + /\bsignal\s+$/.test(prefix) + ) continue; this.addReference(owner, name, 'calls', lineNumber, callMatch.index); } } @@ -310,4 +364,10 @@ export class GDScriptExtractor { const pascal = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(''); return pascal || path.basename(this.filePath); } + + private nodePathReceiverName(nodePath: string): string { + const cleaned = nodePath.replace(/^[$%]/, ''); + const lastSegment = cleaned.split('/').filter(Boolean).pop(); + return lastSegment || cleaned || nodePath; + } } diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts index 10f7cc2e8..cd7aefb5a 100644 --- a/src/extraction/godot-resource-extractor.ts +++ b/src/extraction/godot-resource-extractor.ts @@ -67,14 +67,14 @@ export class GodotResourceExtractor { } private extractSections(fileNodeId: string): void { - let currentNode: Node | null = null; + let currentOwner: Node | null = null; for (let i = 0; i < this.lines.length; i++) { const line = this.lines[i] ?? ''; const lineNumber = i + 1; const section = line.match(/^\[([A-Za-z_]+)([^\]]*)\]/); if (!section) { - if (currentNode) this.extractNodeProperty(currentNode, line, lineNumber); + if (currentOwner) this.extractSectionProperty(currentOwner, line, lineNumber); continue; } @@ -89,7 +89,7 @@ export class GodotResourceExtractor { if (!attrs.has('parent') && !this.rootNode) this.rootNode = node; this.nodesByScenePath.set(scenePath, node); this.addNodeContainment(fileNodeId, node, attrs.get('parent')); - currentNode = node; + currentOwner = node; } else if (type === 'ext_resource') { const resourcePath = attrs.get('path'); const id = attrs.get('id'); @@ -99,33 +99,53 @@ export class GodotResourceExtractor { node.signature = line.trim(); this.addContains(fileNodeId, node.id); this.addReference(fileNodeId, resourcePath, 'references', lineNumber, line.indexOf(resourcePath)); - currentNode = null; + 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); - currentNode = null; + 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); - currentNode = null; + currentOwner = null; } else { - currentNode = null; + currentOwner = null; } } this.extractInlineResourcePaths(fileNodeId); } - private extractNodeProperty(node: Node, line: string, lineNumber: number): void { + private extractSectionProperty(owner: Node, line: string, lineNumber: number): void { const scriptMatch = line.match(/^\s*script\s*=\s*ExtResource\("([^"]+)"\)/); - if (!scriptMatch) return; + if (scriptMatch) { + const resourcePath = this.extResources.get(scriptMatch[1]!); + if (resourcePath) { + this.addReference(owner.id, resourcePath, 'references', lineNumber, line.indexOf('ExtResource')); + } + return; + } - const resourcePath = this.extResources.get(scriptMatch[1]!); - if (!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) return; - this.addReference(node.id, resourcePath, 'references', lineNumber, line.indexOf('ExtResource')); + const value = idMatch[2]!; + const node = this.createNode('constant', value, `${this.filePath}::${idMatch[1]}:${value}`, lineNumber, line.indexOf(value), line.length); + node.signature = line.trim(); + this.addContains(owner.id, node.id); } private extractConnection(fileNodeId: string, attrs: Map, line: string, lineNumber: number): void { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 997a44379..bc31d1838 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -15,10 +15,11 @@ export function matchByFilePath( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { - if (!ref.referenceName.includes('/')) return null; + const referencePath = normalizePathReference(ref.referenceName); + if (!referencePath.includes('/')) return null; // Extract the filename from the path - const fileName = ref.referenceName.split('/').pop(); + const fileName = referencePath.split('/').pop(); if (!fileName) return null; // Search for file nodes with this name @@ -28,7 +29,7 @@ export function matchByFilePath( if (fileNodes.length === 0) return null; // Prefer exact path match on qualified_name - const exactMatch = fileNodes.find(n => n.qualifiedName === ref.referenceName || n.filePath === ref.referenceName); + const exactMatch = fileNodes.find(n => n.qualifiedName === referencePath || n.filePath === referencePath); if (exactMatch) { return { original: ref, @@ -39,7 +40,7 @@ export function matchByFilePath( } // Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches "src/snippets/foo.liquid") - const suffixMatch = fileNodes.find(n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName)); + const suffixMatch = fileNodes.find(n => n.qualifiedName.endsWith(referencePath) || n.filePath.endsWith(referencePath)); if (suffixMatch) { return { original: ref, @@ -62,6 +63,11 @@ export function matchByFilePath( return null; } +function normalizePathReference(referenceName: string): string { + if (referenceName.startsWith('res://')) return referenceName.slice('res://'.length); + return referenceName; +} + /** * Try to resolve a reference by exact name match */ From 5439eeb9b561ebc085a94a4a1b2bc8401597b92a Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 13:01:56 +0800 Subject: [PATCH 05/33] Normalize Godot resource paths in symbol tools --- __tests__/resolution.test.ts | 26 +++++++++++++++++ src/bin/codegraph.ts | 55 ++++++++++++++++++++++++++++++++---- src/mcp/tools.ts | 34 +++++++++++++++------- 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index f140b0afc..b3b04b9eb 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -16,6 +16,7 @@ import { resolveImportPath, extractImportMappings } from '../src/resolution/impo 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; @@ -125,6 +126,31 @@ describe('Resolution Module', () => { 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 prefer same-module candidates over cross-module matches', () => { // Simulates a Python monorepo where multiple apps define navigate() const candidateA: Node = { diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 6bc63b3fd..1fb7f6e85 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -49,6 +49,49 @@ 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; +}; + +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; +} + const importESM = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise; @@ -1236,7 +1279,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(); @@ -1247,7 +1290,7 @@ 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; for (const c of cg.getCallers(match.node.id)) { if (!seen.has(c.node.id)) { @@ -1315,7 +1358,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(); @@ -1326,7 +1369,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)) { @@ -1393,7 +1436,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(); @@ -1406,7 +1449,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/mcp/tools.ts b/src/mcp/tools.ts index 16df373d3..c88fef2ae 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -58,8 +58,16 @@ const CONTAINER_NODE_KINDS = new Set([ 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module', ]); +/** Normalize engine/framework path aliases users commonly type into tools. */ +function normalizeSymbolQuery(symbol: string): string { + if (symbol.startsWith('res://')) return symbol.slice('res://'.length); + return symbol; +} + /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */ function lastQualifierPart(symbol: string): string { + const slashIndex = symbol.lastIndexOf('/'); + if (slashIndex >= 0) return symbol.slice(slashIndex + 1); const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0); return parts[parts.length - 1] ?? symbol; } @@ -1703,8 +1711,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; @@ -1740,26 +1752,27 @@ export class ToolHandler { } private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null { + const normalizedSymbol = normalizeSymbolQuery(symbol); // Use higher limit for qualified lookups (e.g., "Session.request", // "stage_apply::run") since the target may rank lower in FTS when // there are many partial matches across the qualifier parts. - const isQualified = /[.\/]|::/.test(symbol); + const isQualified = /[.\/]|::/.test(normalizedSymbol); const limit = isQualified ? 50 : 10; - let results = cg.searchNodes(symbol, { limit }); + let results = cg.searchNodes(normalizedSymbol, { limit }); // FTS strips colons as a special char, so `stage_apply::run` searches // for the literal `stage_applyrun` and finds nothing. Re-search by // the bare last part and let `matchesSymbol` filter by qualifier. if (isQualified && results.length === 0) { - const tail = lastQualifierPart(symbol); - if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit }); + const tail = lastQualifierPart(normalizedSymbol); + if (tail && tail !== normalizedSymbol) results = cg.searchNodes(tail, { limit }); } if (results.length === 0 || !results[0]) { return null; } - const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol)); + const exactMatches = results.filter(r => this.matchesSymbol(r.node, normalizedSymbol)); if (exactMatches.length === 1) { return { node: exactMatches[0]!.node, note: '' }; @@ -1788,21 +1801,22 @@ export class ToolHandler { * results across all matching symbols (e.g., multiple classes with an `execute` method). */ private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } { - let results = cg.searchNodes(symbol, { limit: 50 }); + const normalizedSymbol = normalizeSymbolQuery(symbol); + let results = cg.searchNodes(normalizedSymbol, { limit: 50 }); // Mirror the fallback in `findSymbol` for qualified queries — FTS // strips colons, so a module-qualified lookup needs a second pass // by the bare last part. - if (results.length === 0 && /[.\/]|::/.test(symbol)) { - const tail = lastQualifierPart(symbol); - if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 }); + if (results.length === 0 && /[.\/]|::/.test(normalizedSymbol)) { + const tail = lastQualifierPart(normalizedSymbol); + if (tail && tail !== normalizedSymbol) results = cg.searchNodes(tail, { limit: 50 }); } if (results.length === 0) { return { nodes: [], note: '' }; } - const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol)); + const exactMatches = results.filter(r => this.matchesSymbol(r.node, normalizedSymbol)); if (exactMatches.length <= 1) { const node = exactMatches[0]?.node ?? results[0]!.node; From bd23e4c83a246a854aca156c6cbae2437f2fa729 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 13:14:08 +0800 Subject: [PATCH 06/33] Resolve Godot node path references --- __tests__/extraction.test.ts | 9 +++++ __tests__/resolution.test.ts | 52 ++++++++++++++++++++++++++++ src/extraction/gdscript-extractor.ts | 37 ++++++++++++++++++++ src/resolution/name-matcher.ts | 6 ++-- 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 7649df951..9af6fbe29 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -153,6 +153,8 @@ func _ready() -> void: var tint = Color(1, 0, 0) $Sprite2D.play() %StatusPanel.refresh() + var template = $MarginContainer/StatusFlow/StatusIconTemplate + var track = get_node("%TrackPanel") setup_player() func setup_player() -> void: @@ -177,6 +179,13 @@ func setup_player() -> void: 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 === '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 === 'TrackPanel')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'PlayerController/TrackPanel')).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); }); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index b3b04b9eb..411536f72 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -151,6 +151,58 @@ describe('Resolution Module', () => { expect(text).toContain('main.gd'); }); + it('should resolve GDScript node path references to Godot scene nodes', async () => { + fs.writeFileSync( + path.join(tempDir, 'status_view.gd'), + [ + 'extends Control', + '@onready var _template: Control = $MarginContainer/StatusFlow/StatusIconTemplate', + '@onready var _track: Control = get_node("%TrackPanel")', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(tempDir, 'status_view.tscn'), + [ + '[gd_scene load_steps=2 format=3]', + '[ext_resource type="Script" path="res://status_view.gd" id="1_status"]', + '[node name="StatusView" type="Control"]', + 'script = ExtResource("1_status")', + '[node name="MarginContainer" type="MarginContainer" parent="."]', + '[node name="StatusFlow" type="HFlowContainer" parent="MarginContainer"]', + '[node name="StatusIconTemplate" type="Control" parent="MarginContainer/StatusFlow"]', + '[node name="TrackPanel" type="Control" parent="."]', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(tempDir, 'other_view.tscn'), + [ + '[gd_scene format=3]', + '[node name="OtherView" type="Control"]', + '[node name="StatusIconTemplate" type="Control" parent="."]', + '[node name="TrackPanel" type="Control" parent="."]', + '', + ].join('\n') + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + const handler = new ToolHandler(cg); + + const templateResult = await handler.execute('codegraph_callers', { + symbol: 'StatusIconTemplate', + projectPath: tempDir, + }); + const trackResult = await handler.execute('codegraph_callers', { + symbol: 'TrackPanel', + projectPath: tempDir, + }); + + expect(templateResult.content[0]?.text ?? '').toContain('_template'); + expect(trackResult.content[0]?.text ?? '').toContain('_track'); + }); + it('should prefer same-module candidates over cross-module matches', () => { // Simulates a Python monorepo where multiple apps define navigate() const candidateA: Node = { diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 351345f59..54e7c9762 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -229,8 +229,17 @@ export class GDScriptExtractor { .filter((node) => (node.kind === 'function' || node.kind === 'method') && node.language === 'gdscript') .map((node) => ({ id: node.id, indent: this.indentOf(this.lines[node.startLine - 1] ?? ''), kind: node.kind, startLine: node.startLine } as FunctionScope)) .sort((a, b) => a.startLine - b.startLine); + const declarationByLine = new Map(); + for (const node of this.nodes) { + if ((node.kind === 'variable' || node.kind === 'constant') && node.language === 'gdscript') { + declarationByLine.set(node.startLine, node); + } + } const ownerForLine = (line: number, indent: number): string => { + const sameLineDeclaration = declarationByLine.get(line); + if (sameLineDeclaration) return sameLineDeclaration.id; + let owner = scriptClass?.id ?? fileNode.id; for (const scope of functionScopes) { if (scope.startLine < line && scope.indent < indent) { @@ -258,6 +267,8 @@ export class GDScriptExtractor { this.addReference(owner, resourceMatch[1]!, 'references', lineNumber, resourceMatch.index); } + this.extractNodePathReferences(owner, code, lineNumber, scriptClass); + const memberCallRegex = /(?:\b([A-Za-z_]\w*)|([$%][A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*))\s*\.\s*([A-Za-z_]\w*)\s*\(/g; let memberCallMatch; while ((memberCallMatch = memberCallRegex.exec(code)) !== null) { @@ -284,6 +295,32 @@ export class GDScriptExtractor { } } + private extractNodePathReferences(owner: string, code: string, lineNumber: number, scriptClass: Node | null): void { + const shorthandRegex = /[$%]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/g; + let shorthandMatch; + while ((shorthandMatch = shorthandRegex.exec(code)) !== null) { + this.addNodePathReference(owner, shorthandMatch[1]!, lineNumber, shorthandMatch.index, scriptClass); + } + + const getNodeRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']+)["']\s*\)/g; + let getNodeMatch; + while ((getNodeMatch = getNodeRegex.exec(code)) !== null) { + this.addNodePathReference(owner, getNodeMatch[1]!, lineNumber, getNodeMatch.index, scriptClass); + } + } + + private addNodePathReference(owner: string, nodePath: string, lineNumber: number, column: number, scriptClass: Node | null): void { + const cleaned = nodePath.replace(/^[$%]/, ''); + const name = this.nodePathReceiverName(cleaned); + this.addReference(owner, name, 'references', lineNumber, column); + if (cleaned.includes('/')) { + this.addReference(owner, cleaned, 'references', lineNumber, column); + } + if (scriptClass && cleaned) { + this.addReference(owner, `${scriptClass.name}/${cleaned}`, 'references', lineNumber, column); + } + } + private createDeclarationNode(kind: NodeKind, name: string, rawLine: string, line: number, indent: number): Node { const column = rawLine.indexOf(name); return this.createNode(kind, name, `${this.filePath}::${name}`, line, column < 0 ? indent : column, line, rawLine.length); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index bc31d1838..f01c97c39 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -116,8 +116,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; } @@ -133,7 +133,7 @@ export function matchByQualifiedName( } // Try partial qualified name match - const parts = ref.referenceName.split(/[:.]/); + const parts = ref.referenceName.split(/[:.\/]/); const lastName = parts[parts.length - 1]; if (lastName) { const partialCandidates = context.getNodesByName(lastName); From 36a2a79177760a46af2d5f048f12ecc37470ed50 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 13:43:58 +0800 Subject: [PATCH 07/33] Enhance Godot signal call extraction --- __tests__/extraction.test.ts | 14 ++++ src/extraction/gdscript-extractor.ts | 120 ++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 9af6fbe29..4c4d1666b 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -155,10 +155,19 @@ func _ready() -> void: %StatusPanel.refresh() var template = $MarginContainer/StatusFlow/StatusIconTemplate var track = get_node("%TrackPanel") + $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 `; const result = extractFromSource('player_controller.gd', code); @@ -179,6 +188,11 @@ func setup_player() -> void: 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); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 54e7c9762..408a23e21 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -236,10 +236,7 @@ export class GDScriptExtractor { } } - const ownerForLine = (line: number, indent: number): string => { - const sameLineDeclaration = declarationByLine.get(line); - if (sameLineDeclaration) return sameLineDeclaration.id; - + const functionOwnerForLine = (line: number, indent: number): string => { let owner = scriptClass?.id ?? fileNode.id; for (const scope of functionScopes) { if (scope.startLine < line && scope.indent < indent) { @@ -249,12 +246,20 @@ export class GDScriptExtractor { return owner; }; + const ownerForLine = (line: number, indent: number): string => { + const sameLineDeclaration = declarationByLine.get(line); + if (sameLineDeclaration) return sameLineDeclaration.id; + + return functionOwnerForLine(line, indent); + }; + for (let i = 0; i < this.lines.length; i++) { const lineNumber = i + 1; const rawLine = this.lines[i] ?? ''; const code = this.stripComment(rawLine); const indent = this.indentOf(rawLine); const owner = ownerForLine(lineNumber, indent); + const functionOwner = functionOwnerForLine(lineNumber, indent); const extendsMatch = code.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}(?:(?:class_name|class)\\s+[A-Za-z_]\\w*\\s+)?extends\\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\\w.]*))`)); if (extendsMatch) { @@ -268,6 +273,8 @@ export class GDScriptExtractor { } this.extractNodePathReferences(owner, code, lineNumber, scriptClass); + this.extractSignalReferences(functionOwner, code, lineNumber); + this.extractCallableReferences(functionOwner, code, lineNumber); const memberCallRegex = /(?:\b([A-Za-z_]\w*)|([$%][A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*))\s*\.\s*([A-Za-z_]\w*)\s*\(/g; let memberCallMatch; @@ -295,6 +302,92 @@ export class GDScriptExtractor { } } + private extractSignalReferences(owner: string, code: string, lineNumber: number): void { + this.extractSignalConnectReferences(owner, code, lineNumber); + this.extractSignalEmitReferences(owner, code, lineNumber); + } + + private extractSignalConnectReferences(owner: string, code: string, lineNumber: number): void { + const memberConnectRegex = /\b(?:([A-Za-z_]\w*)|([$%][A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*))\s*\.\s*([A-Za-z_]\w*)\s*\.\s*connect\s*\(/g; + let memberConnectMatch; + while ((memberConnectMatch = memberConnectRegex.exec(code)) !== null) { + const receiver = memberConnectMatch[1] || this.nodePathReceiverName(memberConnectMatch[2]!); + const signalName = memberConnectMatch[3]!; + this.addReference(owner, signalName, 'references', lineNumber, memberConnectMatch.index); + this.addReference(owner, `${receiver}.${signalName}`, 'references', lineNumber, memberConnectMatch.index); + + const argsStart = memberConnectRegex.lastIndex; + const argsEnd = this.findCallEnd(code, argsStart - 1); + if (argsEnd > argsStart) { + this.addCallableTargetReferences(owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); + } + } + + const bareConnectRegex = /\b([A-Za-z_]\w*)\s*\.\s*connect\s*\(/g; + let bareConnectMatch; + while ((bareConnectMatch = bareConnectRegex.exec(code)) !== null) { + const signalName = bareConnectMatch[1]!; + if (signalName === 'node') continue; + this.addReference(owner, signalName, 'references', lineNumber, bareConnectMatch.index); + + const argsStart = bareConnectRegex.lastIndex; + const argsEnd = this.findCallEnd(code, argsStart - 1); + if (argsEnd > argsStart) { + this.addCallableTargetReferences(owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); + } + } + + const legacyConnectRegex = /\bconnect\s*\(\s*(?:&)?["']([^"']+)["']\s*,/g; + let legacyConnectMatch; + while ((legacyConnectMatch = legacyConnectRegex.exec(code)) !== null) { + this.addReference(owner, legacyConnectMatch[1]!, 'references', lineNumber, legacyConnectMatch.index); + + const argsStart = legacyConnectRegex.lastIndex; + const argsEnd = this.findCallEnd(code, code.indexOf('(', legacyConnectMatch.index)); + if (argsEnd > argsStart) { + this.addCallableTargetReferences(owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); + } + } + } + + private extractSignalEmitReferences(owner: string, code: string, lineNumber: number): void { + const memberEmitRegex = /\b([A-Za-z_]\w*)\s*\.\s*emit\s*\(/g; + let memberEmitMatch; + while ((memberEmitMatch = memberEmitRegex.exec(code)) !== null) { + this.addReference(owner, memberEmitMatch[1]!, 'calls', lineNumber, memberEmitMatch.index); + } + + const emitSignalRegex = /\bemit_signal\s*\(\s*(?:&)?["']([^"']+)["']/g; + let emitSignalMatch; + while ((emitSignalMatch = emitSignalRegex.exec(code)) !== null) { + this.addReference(owner, emitSignalMatch[1]!, 'calls', lineNumber, emitSignalMatch.index); + } + } + + private addCallableTargetReferences(owner: string, args: string, lineNumber: number, argsColumn: number): void { + const callableRegex = /\bCallable\s*\(\s*(?:self|this|[A-Za-z_]\w*)\s*,\s*["']([A-Za-z_]\w*)["']\s*\)/g; + let callableMatch; + while ((callableMatch = callableRegex.exec(args)) !== null) { + this.addReference(owner, callableMatch[1]!, 'calls', lineNumber, argsColumn + callableMatch.index); + } + + const directHandlerMatch = args.match(/^\s*([A-Za-z_]\w*)\b/); + if (directHandlerMatch) { + const name = directHandlerMatch[1]!; + if (!KEYWORDS.has(name) && !GODOT_BUILT_IN_CALLS.has(name) && name !== 'func') { + this.addReference(owner, name, 'calls', lineNumber, argsColumn + args.indexOf(name)); + } + } + } + + private extractCallableReferences(owner: string, code: string, lineNumber: number): void { + const callableRegex = /\bCallable\s*\(\s*(?:self|this|[A-Za-z_]\w*)\s*,\s*["']([A-Za-z_]\w*)["']\s*\)/g; + let callableMatch; + while ((callableMatch = callableRegex.exec(code)) !== null) { + this.addReference(owner, callableMatch[1]!, 'calls', lineNumber, callableMatch.index); + } + } + private extractNodePathReferences(owner: string, code: string, lineNumber: number, scriptClass: Node | null): void { const shorthandRegex = /[$%]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/g; let shorthandMatch; @@ -383,6 +476,25 @@ export class GDScriptExtractor { return line; } + private findCallEnd(code: string, openingParenIndex: number): number { + let depth = 0; + let inSingle = false; + let inDouble = false; + for (let i = openingParenIndex; i < code.length; i++) { + const char = code[i]; + const prev = code[i - 1]; + if (char === "'" && !inDouble && prev !== '\\') inSingle = !inSingle; + if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble; + if (inSingle || inDouble) continue; + if (char === '(') depth += 1; + if (char === ')') { + depth -= 1; + if (depth === 0) return i; + } + } + return code.length; + } + private getLineNumber(index: number): number { return this.source.substring(0, index).split('\n').length; } From 1675cbcbac3cf33fb4e85d928e27a268ef6bdd69 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 14:13:11 +0800 Subject: [PATCH 08/33] Extract Godot scene instance resource references --- __tests__/extraction.test.ts | 6 ++++++ src/extraction/godot-resource-extractor.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 4c4d1666b..7d2c65931 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -252,20 +252,26 @@ describe('Godot Resource Extraction', () => { [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.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 === 'res://status_icon.tscn')).toBe(true); + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'res://status_icon.tscn' && 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); }); diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts index cd7aefb5a..6dcdf0fc1 100644 --- a/src/extraction/godot-resource-extractor.ts +++ b/src/extraction/godot-resource-extractor.ts @@ -89,6 +89,7 @@ export class GodotResourceExtractor { if (!attrs.has('parent') && !this.rootNode) this.rootNode = node; this.nodesByScenePath.set(scenePath, node); this.addNodeContainment(fileNodeId, node, attrs.get('parent')); + this.extractNodeInstanceReference(node.id, attrs, line, lineNumber); currentOwner = node; } else if (type === 'ext_resource') { const resourcePath = attrs.get('path'); @@ -129,6 +130,19 @@ export class GodotResourceExtractor { this.extractInlineResourcePaths(fileNodeId); } + private extractNodeInstanceReference(ownerId: string, 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(ownerId, resourcePath, 'references', lineNumber, line.indexOf('instance=')); + } + private extractSectionProperty(owner: Node, line: string, lineNumber: number): void { const scriptMatch = line.match(/^\s*script\s*=\s*ExtResource\("([^"]+)"\)/); if (scriptMatch) { From a858dad027e13660d6dc63695d4c418612501b48 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 14:21:43 +0800 Subject: [PATCH 09/33] Add Godot resource alias references --- __tests__/extraction.test.ts | 11 ++++-- src/extraction/godot-resource-extractor.ts | 46 ++++++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 7d2c65931..783f5d4ac 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -252,7 +252,7 @@ describe('Godot Resource Extraction', () => { [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.tscn" id="2_status"] +[ext_resource type="PackedScene" path="res://status_icon_template.tscn" id="2_status"] [node name="Player" type="Node2D"] script = ExtResource("1_script") @@ -270,8 +270,13 @@ script = ExtResource("1_script") 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 === 'res://status_icon.tscn')).toBe(true); - expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'res://status_icon.tscn' && result.nodes.some((n) => n.id === r.fromNodeId && n.name === 'StatusIcon'))).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); }); diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts index 6dcdf0fc1..b54d912e8 100644 --- a/src/extraction/godot-resource-extractor.ts +++ b/src/extraction/godot-resource-extractor.ts @@ -89,7 +89,7 @@ export class GodotResourceExtractor { if (!attrs.has('parent') && !this.rootNode) this.rootNode = node; this.nodesByScenePath.set(scenePath, node); this.addNodeContainment(fileNodeId, node, attrs.get('parent')); - this.extractNodeInstanceReference(node.id, attrs, line, lineNumber); + this.extractNodeInstanceReference(node, attrs, line, lineNumber); currentOwner = node; } else if (type === 'ext_resource') { const resourcePath = attrs.get('path'); @@ -130,7 +130,7 @@ export class GodotResourceExtractor { this.extractInlineResourcePaths(fileNodeId); } - private extractNodeInstanceReference(ownerId: string, attrs: Map, line: string, lineNumber: number): void { + private extractNodeInstanceReference(owner: Node, attrs: Map, line: string, lineNumber: number): void { const instance = attrs.get('instance'); if (!instance) return; @@ -140,7 +140,9 @@ export class GodotResourceExtractor { const resourcePath = this.extResources.get(extResourceMatch[1]!); if (!resourcePath) return; - this.addReference(ownerId, resourcePath, 'references', lineNumber, line.indexOf('instance=')); + this.addReference(owner.id, resourcePath, 'references', lineNumber, line.indexOf('instance=')); + this.addGodotResourceAliasReference(owner.id, resourcePath, 'references', lineNumber, line.indexOf('instance=')); + this.addGodotInstanceNameAliasReference(owner, 'references', lineNumber, line.indexOf('instance=')); } private extractSectionProperty(owner: Node, line: string, lineNumber: number): void { @@ -149,6 +151,7 @@ export class GodotResourceExtractor { const resourcePath = this.extResources.get(scriptMatch[1]!); if (resourcePath) { this.addReference(owner.id, resourcePath, 'references', lineNumber, line.indexOf('ExtResource')); + this.addGodotResourceAliasReference(owner.id, resourcePath, 'references', lineNumber, line.indexOf('ExtResource')); } return; } @@ -284,6 +287,43 @@ export class GodotResourceExtractor { }); } + 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; } From fa795ce2ff0aeb77410a24d5ecd0acce92d80dd4 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 14:32:19 +0800 Subject: [PATCH 10/33] Aggregate Godot scene instance callers --- __tests__/resolution.test.ts | 41 ++++++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 10 +++++++++ src/mcp/tools.ts | 11 ++++++++++ 3 files changed, 62 insertions(+) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 411536f72..f40cb0e3b 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -203,6 +203,47 @@ describe('Resolution Module', () => { expect(trackResult.content[0]?.text ?? '').toContain('_track'); }); + it('should include Godot scene instances when querying callers by instance node name', async () => { + fs.writeFileSync( + path.join(tempDir, 'battle_status.gd'), + 'class_name BattleStatusView\nextends Control\n' + ); + fs.writeFileSync( + path.join(tempDir, 'battle_status.tscn'), + [ + '[gd_scene load_steps=2 format=3]', + '[ext_resource type="Script" path="res://battle_status.gd" id="1_status_script"]', + '[node name="BattleStatusView" type="Control"]', + 'script = ExtResource("1_status_script")', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(tempDir, 'control_middle.tscn'), + [ + '[gd_scene load_steps=2 format=3]', + '[ext_resource type="PackedScene" path="res://battle_status.tscn" id="1_status_scene"]', + '[node name="ControlMiddle" type="Control"]', + '[node name="BattleStatusView" parent="." instance=ExtResource("1_status_scene")]', + '', + ].join('\n') + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + const handler = new ToolHandler(cg); + + const result = await handler.execute('codegraph_callers', { + symbol: 'BattleStatusView', + projectPath: tempDir, + }); + + const text = result.content[0]?.text ?? ''; + expect(result.isError).not.toBe(true); + expect(text).toContain('control_middle.tscn:4'); + expect(text).toContain('BattleStatusView (component)'); + }); + it('should prefer same-module candidates over cross-module matches', () => { // Simulates a Python monorepo where multiple apps define navigate() const candidateA: Node = { diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 1fb7f6e85..046c2f8b9 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -68,6 +68,7 @@ type CliSearchNode = { filePath: string; qualifiedName: string; startLine?: number; + signature?: string; }; function nodeMatchesSymbol(node: CliSearchNode, symbol: string): boolean { @@ -92,6 +93,11 @@ function findCliSymbolMatches( 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; @@ -1292,6 +1298,10 @@ program for (const match of matches) { 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); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index c88fef2ae..c732fac50 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -860,6 +860,10 @@ export class ToolHandler { const seen = new Set(); const allCallers: Node[] = []; for (const node of allMatches.nodes) { + if (this.isGodotSceneInstanceComponent(node) && !seen.has(node.id)) { + seen.add(node.id); + allCallers.push(node); + } for (const c of cg.getCallers(node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); @@ -876,6 +880,13 @@ export class ToolHandler { return this.textResult(this.truncateOutput(formatted)); } + private isGodotSceneInstanceComponent(node: Node): boolean { + return node.kind === 'component' + && node.language === 'godot_resource' + && node.filePath.endsWith('.tscn') + && (node.signature ?? '').includes('instance=ExtResource'); + } + /** * Handle codegraph_callees */ From e04870013342f7ff0dcbb76027cb3b119abdd1c0 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 14:36:26 +0800 Subject: [PATCH 11/33] Resolve GDScript constant node paths --- __tests__/extraction.test.ts | 9 +++++++++ src/extraction/gdscript-extractor.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 783f5d4ac..35d524530 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -144,6 +144,8 @@ class_name PlayerController signal health_changed(value: int) const MAX_HP := 100 +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 @@ -154,6 +156,8 @@ func _ready() -> void: $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 track = get_node("%TrackPanel") $Sprite2D.pressed.connect(_on_sprite_pressed) connect("health_changed", Callable(self, "_on_health_changed")) @@ -178,6 +182,8 @@ func _on_health_changed(value: int) -> void: 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 === '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); @@ -198,6 +204,9 @@ func _on_health_changed(value: int) -> void: 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.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 === 'calls' && r.referenceName === 'Color')).toBe(false); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 408a23e21..dab0efc21 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -84,6 +84,7 @@ export class GDScriptExtractor { private edges: Edge[] = []; private unresolvedReferences: UnresolvedReference[] = []; private errors: ExtractionError[] = []; + private stringConstants = new Map(); constructor(filePath: string, source: string) { this.filePath = filePath; @@ -220,6 +221,10 @@ export class GDScriptExtractor { const node = this.createDeclarationNode(kind, varMatch[2]!, rawLine, lineNumber, indent); node.signature = trimmed; this.addContains(scopes[scopes.length - 1]!.id, node.id); + if (kind === 'constant') { + const stringValueMatch = trimmed.match(/:=?\s*["']([^"']+)["']/); + if (stringValueMatch) this.stringConstants.set(varMatch[2]!, stringValueMatch[1]!); + } } } } @@ -400,6 +405,15 @@ export class GDScriptExtractor { while ((getNodeMatch = getNodeRegex.exec(code)) !== null) { this.addNodePathReference(owner, getNodeMatch[1]!, lineNumber, getNodeMatch.index, scriptClass); } + + const getNodeConstantRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*([A-Za-z_]\w*)\s*\)/g; + let getNodeConstantMatch; + while ((getNodeConstantMatch = getNodeConstantRegex.exec(code)) !== null) { + const constName = getNodeConstantMatch[1]!; + const nodePath = this.stringConstants.get(constName); + if (!nodePath) continue; + this.addNodePathReference(owner, nodePath, lineNumber, getNodeConstantMatch.index, scriptClass); + } } private addNodePathReference(owner: string, nodePath: string, lineNumber: number, column: number, scriptClass: Node | null): void { From d600b594246a31eeee6f579780876fe992f2b2d1 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 14:41:11 +0800 Subject: [PATCH 12/33] Extract GDScript dynamic node names --- __tests__/extraction.test.ts | 5 +++++ src/extraction/gdscript-extractor.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 35d524530..386f9b2f9 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -159,6 +159,9 @@ func _ready() -> void: var template_from_const = get_node_or_null(TEMPLATE_PATH) var row_from_const = get_node_or_null(CARD_ROW_PATH) var track = get_node("%TrackPanel") + 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() @@ -188,6 +191,7 @@ func _on_health_changed(value: int) -> void: expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'move_ratio')).toBe(true); expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'shared_counter')).toBe(true); expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'health_changed')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'component' && n.name === '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); @@ -209,6 +213,7 @@ func _on_health_changed(value: int) -> void: expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'PlayerController/RewardList/CardRewardTemplate')).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 === '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); }); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index dab0efc21..3b5bc025a 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -226,6 +226,22 @@ export class GDScriptExtractor { if (stringValueMatch) this.stringConstants.set(varMatch[2]!, stringValueMatch[1]!); } } + + const dynamicNodeNameMatch = trimmed.match(/\b[A-Za-z_]\w*\s*\.\s*name\s*=\s*["']([A-Za-z_]\w*)["']/); + if (dynamicNodeNameMatch) { + const nodeName = dynamicNodeNameMatch[1]!; + const node = this.createNode( + 'component', + nodeName, + `${this.filePath}::dynamic_node:${nodeName}:${lineNumber}`, + lineNumber, + rawLine.indexOf(nodeName), + lineNumber, + rawLine.length + ); + node.signature = trimmed; + this.addContains(scopes[scopes.length - 1]!.id, node.id); + } } } From 6d0996b7053236d9b06f1ba9362be146f3f68dc9 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 14:45:36 +0800 Subject: [PATCH 13/33] Support GDScript formatted node names --- __tests__/extraction.test.ts | 5 ++ src/extraction/gdscript-extractor.ts | 70 +++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 386f9b2f9..3580d07ce 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -159,6 +159,8 @@ func _ready() -> void: var template_from_const = get_node_or_null(TEMPLATE_PATH) var row_from_const = get_node_or_null(CARD_ROW_PATH) var track = get_node("%TrackPanel") + var row_name := "CardReward%d" % reward_index + var extra_row = get_node_or_null("CardReward%d" % reward_index) var reward_button = get_node_or_null("LootCardRewardButton") reward_button = Button.new() reward_button.name = "LootCardRewardButton" @@ -191,6 +193,7 @@ func _on_health_changed(value: int) -> void: expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'move_ratio')).toBe(true); expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'shared_counter')).toBe(true); expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'health_changed')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'component' && n.name === '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); @@ -213,6 +216,8 @@ func _on_health_changed(value: int) -> void: expect(result.unresolvedReferences.some((r) => r.referenceKind === 'references' && r.referenceName === 'PlayerController/RewardList/CardRewardTemplate')).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.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); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 3b5bc025a..59391339b 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -85,6 +85,7 @@ export class GDScriptExtractor { private unresolvedReferences: UnresolvedReference[] = []; private errors: ExtractionError[] = []; private stringConstants = new Map(); + private dynamicNodeNames = new Set(); constructor(filePath: string, source: string) { this.filePath = filePath; @@ -229,18 +230,12 @@ export class GDScriptExtractor { const dynamicNodeNameMatch = trimmed.match(/\b[A-Za-z_]\w*\s*\.\s*name\s*=\s*["']([A-Za-z_]\w*)["']/); if (dynamicNodeNameMatch) { - const nodeName = dynamicNodeNameMatch[1]!; - const node = this.createNode( - 'component', - nodeName, - `${this.filePath}::dynamic_node:${nodeName}:${lineNumber}`, - lineNumber, - rawLine.indexOf(nodeName), - lineNumber, - rawLine.length - ); - node.signature = trimmed; - this.addContains(scopes[scopes.length - 1]!.id, node.id); + this.addDynamicNodeNameDeclaration(dynamicNodeNameMatch[1]!, rawLine, trimmed, lineNumber, scopes[scopes.length - 1]!.id); + } + + const formattedNodeName = this.extractFormattedNodePathBase(trimmed); + if (formattedNodeName) { + this.addDynamicNodeNameDeclaration(formattedNodeName, rawLine, trimmed, lineNumber, scopes[scopes.length - 1]!.id); } } } @@ -422,6 +417,24 @@ export class GDScriptExtractor { this.addNodePathReference(owner, getNodeMatch[1]!, lineNumber, getNodeMatch.index, scriptClass); } + const getNodeFormattedRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']*%d[^"']*)["']\s*%/g; + let getNodeFormattedMatch; + while ((getNodeFormattedMatch = getNodeFormattedRegex.exec(code)) !== null) { + const formattedNodePath = this.formattedNodePathBase(getNodeFormattedMatch[1]!); + if (formattedNodePath) { + this.addNodePathReference(owner, formattedNodePath, lineNumber, getNodeFormattedMatch.index, scriptClass); + } + } + + const formattedNodePathVariableRegex = /\b[A-Za-z_]\w*(?:_name|_path)\s*:=?\s*["']([^"']*%d[^"']*)["']\s*%/g; + let formattedNodePathVariableMatch; + while ((formattedNodePathVariableMatch = formattedNodePathVariableRegex.exec(code)) !== null) { + const formattedNodePath = this.formattedNodePathBase(formattedNodePathVariableMatch[1]!); + if (formattedNodePath) { + this.addNodePathReference(owner, formattedNodePath, lineNumber, formattedNodePathVariableMatch.index, scriptClass); + } + } + const getNodeConstantRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*([A-Za-z_]\w*)\s*\)/g; let getNodeConstantMatch; while ((getNodeConstantMatch = getNodeConstantRegex.exec(code)) !== null) { @@ -444,6 +457,39 @@ export class GDScriptExtractor { } } + private addDynamicNodeNameDeclaration(name: string, rawLine: string, signature: string, lineNumber: number, owner: string): void { + if (this.dynamicNodeNames.has(name)) return; + this.dynamicNodeNames.add(name); + const node = this.createNode( + 'component', + name, + `${this.filePath}::dynamic_node:${name}:${lineNumber}`, + lineNumber, + rawLine.indexOf(name), + lineNumber, + rawLine.length + ); + node.signature = signature; + this.addContains(owner, node.id); + } + + private extractFormattedNodePathBase(code: string): string | null { + if (!/\b(?:get_node|get_node_or_null|has_node)\s*\(/.test(code) && !/\b[A-Za-z_]\w*\s*:=?\s*["'][^"']*%d/.test(code)) { + return null; + } + + const formattedStringMatch = code.match(/["']([^"']*%d[^"']*)["']\s*%/); + if (!formattedStringMatch) return null; + return this.formattedNodePathBase(formattedStringMatch[1]!); + } + + private formattedNodePathBase(nodePath: string): string | null { + if (!nodePath.includes('%d')) return null; + const stripped = nodePath.replace(/%d/g, ''); + if (!/^[A-Z_][A-Za-z0-9_]*(?:\/[A-Z_][A-Za-z0-9_]*)*$/.test(stripped)) return null; + return stripped; + } + private createDeclarationNode(kind: NodeKind, name: string, rawLine: string, line: number, indent: number): Node { const column = rawLine.indexOf(name); return this.createNode(kind, name, `${this.filePath}::${name}`, line, column < 0 ? indent : column, line, rawLine.length); From 24b821e491aca70d389ad162397c4c985317917d Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 14:48:29 +0800 Subject: [PATCH 14/33] Resolve GDScript node path aliases --- __tests__/extraction.test.ts | 2 ++ src/extraction/gdscript-extractor.ts | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 3580d07ce..a4c8f560f 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -161,6 +161,7 @@ func _ready() -> void: var track = get_node("%TrackPanel") 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" @@ -218,6 +219,7 @@ func _on_health_changed(value: int) -> void: 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); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 59391339b..2129d41d4 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -86,6 +86,7 @@ export class GDScriptExtractor { private errors: ExtractionError[] = []; private stringConstants = new Map(); private dynamicNodeNames = new Set(); + private nodePathAliases = new Map>(); constructor(filePath: string, source: string) { this.filePath = filePath; @@ -288,7 +289,7 @@ export class GDScriptExtractor { this.addReference(owner, resourceMatch[1]!, 'references', lineNumber, resourceMatch.index); } - this.extractNodePathReferences(owner, code, lineNumber, scriptClass); + this.extractNodePathReferences(owner, code, lineNumber, scriptClass, functionOwner); this.extractSignalReferences(functionOwner, code, lineNumber); this.extractCallableReferences(functionOwner, code, lineNumber); @@ -404,7 +405,7 @@ export class GDScriptExtractor { } } - private extractNodePathReferences(owner: string, code: string, lineNumber: number, scriptClass: Node | null): void { + private extractNodePathReferences(owner: string, code: string, lineNumber: number, scriptClass: Node | null, aliasOwner: string): void { const shorthandRegex = /[$%]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/g; let shorthandMatch; while ((shorthandMatch = shorthandRegex.exec(code)) !== null) { @@ -431,6 +432,8 @@ export class GDScriptExtractor { while ((formattedNodePathVariableMatch = formattedNodePathVariableRegex.exec(code)) !== null) { const formattedNodePath = this.formattedNodePathBase(formattedNodePathVariableMatch[1]!); if (formattedNodePath) { + const variableName = (code.slice(formattedNodePathVariableMatch.index).match(/\b([A-Za-z_]\w*(?:_name|_path))\s*:=?/) || [])[1]; + if (variableName) this.addNodePathAlias(aliasOwner, variableName, formattedNodePath); this.addNodePathReference(owner, formattedNodePath, lineNumber, formattedNodePathVariableMatch.index, scriptClass); } } @@ -439,7 +442,7 @@ export class GDScriptExtractor { let getNodeConstantMatch; while ((getNodeConstantMatch = getNodeConstantRegex.exec(code)) !== null) { const constName = getNodeConstantMatch[1]!; - const nodePath = this.stringConstants.get(constName); + const nodePath = this.lookupNodePathAlias(aliasOwner, constName) ?? this.stringConstants.get(constName); if (!nodePath) continue; this.addNodePathReference(owner, nodePath, lineNumber, getNodeConstantMatch.index, scriptClass); } @@ -473,6 +476,15 @@ export class GDScriptExtractor { this.addContains(owner, node.id); } + private addNodePathAlias(owner: string, alias: string, nodePath: string): void { + if (!this.nodePathAliases.has(owner)) this.nodePathAliases.set(owner, new Map()); + this.nodePathAliases.get(owner)!.set(alias, nodePath); + } + + private lookupNodePathAlias(owner: string, alias: string): string | undefined { + return this.nodePathAliases.get(owner)?.get(alias); + } + private extractFormattedNodePathBase(code: string): string | null { if (!/\b(?:get_node|get_node_or_null|has_node)\s*\(/.test(code) && !/\b[A-Za-z_]\w*\s*:=?\s*["'][^"']*%d/.test(code)) { return null; From 4d9b53d6c8996c70fbb94671c8460a2549803281 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 14:58:36 +0800 Subject: [PATCH 15/33] Index GDScript node name constants --- __tests__/extraction.test.ts | 5 +++++ src/extraction/gdscript-extractor.ts | 13 ++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index a4c8f560f..7b9e6f1aa 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -144,6 +144,7 @@ class_name PlayerController signal health_changed(value: int) const MAX_HP := 100 +const DYNAMIC_UI_SOUND_CONTROLLER_NAME := "MainDynamicUISoundController" const TEMPLATE_PATH := "MarginContainer/StatusFlow/StatusIconTemplate" const CARD_ROW_PATH := "RewardList/CardRewardTemplate" @onready var sprite := $Sprite2D @@ -158,6 +159,7 @@ func _ready() -> void: 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 row_name := "CardReward%d" % reward_index var extra_row = get_node_or_null("CardReward%d" % reward_index) @@ -188,12 +190,14 @@ func _on_health_changed(value: int) -> void: 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 === 'TEMPLATE_PATH')).toBe(true); expect(result.nodes.some((n) => n.kind === 'constant' && n.name === 'CARD_ROW_PATH')).toBe(true); expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'sprite')).toBe(true); expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'move_ratio')).toBe(true); expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'shared_counter')).toBe(true); expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'health_changed')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'MainDynamicUISoundController')).toBe(true); expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'CardReward')).toBe(true); expect(result.nodes.some((n) => n.kind === 'component' && n.name === 'LootCardRewardButton')).toBe(true); @@ -215,6 +219,7 @@ func _on_health_changed(value: int) -> void: 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.some((r) => r.referenceKind === 'references' && r.referenceName === 'MainDynamicUISoundController')).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); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 2129d41d4..f2bbec2f4 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -225,7 +225,14 @@ export class GDScriptExtractor { this.addContains(scopes[scopes.length - 1]!.id, node.id); if (kind === 'constant') { const stringValueMatch = trimmed.match(/:=?\s*["']([^"']+)["']/); - if (stringValueMatch) this.stringConstants.set(varMatch[2]!, stringValueMatch[1]!); + if (stringValueMatch) { + const constName = varMatch[2]!; + const stringValue = stringValueMatch[1]!; + this.stringConstants.set(constName, stringValue); + if (/_NAME$/.test(constName) && this.isSimpleNodeName(stringValue)) { + this.addDynamicNodeNameDeclaration(stringValue, rawLine, trimmed, lineNumber, scopes[scopes.length - 1]!.id); + } + } } } @@ -502,6 +509,10 @@ export class GDScriptExtractor { return stripped; } + private isSimpleNodeName(value: string): boolean { + return /^[A-Z_][A-Za-z0-9_]*$/.test(value); + } + private createDeclarationNode(kind: NodeKind, name: string, rawLine: string, line: number, indent: number): Node { const column = rawLine.indexOf(name); return this.createNode(kind, name, `${this.filePath}::${name}`, line, column < 0 ? indent : column, line, rawLine.length); From 82d49bad30c076c90265574c885129b5ea9d8d2f Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 15:03:43 +0800 Subject: [PATCH 16/33] Extract GDScript node lookup helpers --- __tests__/extraction.test.ts | 5 ++++- src/extraction/gdscript-extractor.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 7b9e6f1aa..740f3c740 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -161,6 +161,8 @@ func _ready() -> void: 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 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) @@ -219,7 +221,8 @@ func _on_health_changed(value: int) -> void: 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.some((r) => r.referenceKind === 'references' && r.referenceName === 'MainDynamicUISoundController')).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 === '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); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index f2bbec2f4..99604ae09 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -425,6 +425,18 @@ export class GDScriptExtractor { this.addNodePathReference(owner, getNodeMatch[1]!, lineNumber, getNodeMatch.index, scriptClass); } + const findChildRegex = /\bfind_child\s*\(\s*["']([^"']+)["']/g; + let findChildMatch; + while ((findChildMatch = findChildRegex.exec(code)) !== null) { + this.addNodePathReference(owner, findChildMatch[1]!, lineNumber, findChildMatch.index, scriptClass); + } + + const projectFindNodeRegex = /\b_find_node\s*\(\s*[^,\n]+,\s*["']([^"']+)["']/g; + let projectFindNodeMatch; + while ((projectFindNodeMatch = projectFindNodeRegex.exec(code)) !== null) { + this.addNodePathReference(owner, projectFindNodeMatch[1]!, lineNumber, projectFindNodeMatch.index, scriptClass); + } + const getNodeFormattedRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']*%d[^"']*)["']\s*%/g; let getNodeFormattedMatch; while ((getNodeFormattedMatch = getNodeFormattedRegex.exec(code)) !== null) { From 0f9bc0ed6155e590c71dc5aa79b1c736bfc7851e Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 15:08:09 +0800 Subject: [PATCH 17/33] Resolve GDScript node lookup wrappers --- __tests__/extraction.test.ts | 5 ++ src/extraction/gdscript-extractor.ts | 88 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 740f3c740..2b3824eb2 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -162,6 +162,7 @@ func _ready() -> void: 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 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) @@ -182,6 +183,9 @@ func _on_sprite_pressed() -> void: 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); @@ -223,6 +227,7 @@ func _on_health_changed(value: int) -> void: 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 === '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); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 99604ae09..9de3a36fb 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -87,6 +87,7 @@ export class GDScriptExtractor { private stringConstants = new Map(); private dynamicNodeNames = new Set(); private nodePathAliases = new Map>(); + private nodeLookupHelperArgumentIndex = new Map(); constructor(filePath: string, source: string) { this.filePath = filePath; @@ -253,6 +254,7 @@ export class GDScriptExtractor { .filter((node) => (node.kind === 'function' || node.kind === 'method') && node.language === 'gdscript') .map((node) => ({ id: node.id, indent: this.indentOf(this.lines[node.startLine - 1] ?? ''), kind: node.kind, startLine: node.startLine } as FunctionScope)) .sort((a, b) => a.startLine - b.startLine); + this.extractNodeLookupHelpers(functionScopes); const declarationByLine = new Map(); for (const node of this.nodes) { if ((node.kind === 'variable' || node.kind === 'constant') && node.language === 'gdscript') { @@ -465,6 +467,92 @@ export class GDScriptExtractor { if (!nodePath) continue; this.addNodePathReference(owner, nodePath, lineNumber, getNodeConstantMatch.index, scriptClass); } + + const helperCallRegex = /\b([A-Za-z_]\w*)\s*\(([^)]*)\)/g; + let helperCallMatch; + while ((helperCallMatch = helperCallRegex.exec(code)) !== null) { + const helperName = helperCallMatch[1]!; + const argumentIndex = this.nodeLookupHelperArgumentIndex.get(helperName); + if (argumentIndex === undefined) continue; + const args = this.splitCallArguments(helperCallMatch[2]!); + const stringArg = args[argumentIndex]?.match(/^\s*["']([^"']+)["']\s*$/); + if (!stringArg) continue; + this.addNodePathReference(owner, stringArg[1]!, lineNumber, helperCallMatch.index, scriptClass); + } + } + + private extractNodeLookupHelpers(functionScopes: FunctionScope[]): void { + if (this.nodeLookupHelperArgumentIndex.size > 0) return; + + for (let i = 0; i < functionScopes.length; i++) { + const scope = functionScopes[i]!; + const node = this.nodes.find((candidate) => candidate.id === scope.id); + if (!node) continue; + + const functionLine = this.stripComment(this.lines[scope.startLine - 1] ?? ''); + const params = this.extractFunctionParameterNames(functionLine); + if (params.length === 0) continue; + + const endLineExclusive = this.functionBodyEndLine(scope, functionScopes, i); + const body = this.lines + .slice(scope.startLine, endLineExclusive - 1) + .map((line) => this.stripComment(line)) + .join('\n'); + + for (let paramIndex = 0; paramIndex < params.length; paramIndex++) { + const paramName = params[paramIndex]!; + const escaped = this.escapeRegExp(paramName); + const directLookupRegex = new RegExp(`\\b(?:get_node|get_node_or_null|has_node|find_child)\\s*\\(\\s*${escaped}\\b`); + const projectLookupRegex = new RegExp(`\\b_find_node\\s*\\([^,\\n]+,\\s*${escaped}\\b`); + if (directLookupRegex.test(body) || projectLookupRegex.test(body)) { + this.nodeLookupHelperArgumentIndex.set(node.name, paramIndex); + break; + } + } + } + } + + private extractFunctionParameterNames(functionLine: string): string[] { + const match = functionLine.match(/\bfunc\s+[A-Za-z_]\w*\s*\(([^)]*)\)/); + if (!match) return []; + return this.splitCallArguments(match[1]!) + .map((arg) => (arg.trim().match(/^([A-Za-z_]\w*)/) || [])[1]) + .filter((name): name is string => Boolean(name)); + } + + private functionBodyEndLine(scope: FunctionScope, functionScopes: FunctionScope[], scopeIndex: number): number { + for (let i = scopeIndex + 1; i < functionScopes.length; i++) { + const next = functionScopes[i]!; + if (next.indent <= scope.indent) return next.startLine; + } + return this.lines.length + 1; + } + + private splitCallArguments(args: string): string[] { + const result: string[] = []; + let start = 0; + let depth = 0; + let inSingle = false; + let inDouble = false; + for (let i = 0; i < args.length; i++) { + const char = args[i]; + const prev = args[i - 1]; + if (char === "'" && !inDouble && prev !== '\\') inSingle = !inSingle; + if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble; + if (inSingle || inDouble) continue; + if (char === '(' || char === '[' || char === '{') depth += 1; + if (char === ')' || char === ']' || char === '}') depth -= 1; + if (char === ',' && depth === 0) { + result.push(args.slice(start, i)); + start = i + 1; + } + } + result.push(args.slice(start)); + return result; + } + + private escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } private addNodePathReference(owner: string, nodePath: string, lineNumber: number, column: number, scriptClass: Node | null): void { From f459ebcff916e489738f2751b77bde67b2713f44 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 15:16:35 +0800 Subject: [PATCH 18/33] Resolve GDScript node lookup aliases --- __tests__/extraction.test.ts | 10 +++++ src/extraction/gdscript-extractor.ts | 55 ++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 2b3824eb2..ff1c3c453 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -145,6 +145,7 @@ 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 @@ -163,6 +164,11 @@ func _ready() -> void: 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) @@ -197,6 +203,7 @@ func _find_label(label_name: String) -> Label: 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); @@ -228,6 +235,9 @@ func _find_label(label_name: String) -> Label: 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); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 9de3a36fb..fa5692b17 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -225,7 +225,7 @@ export class GDScriptExtractor { node.signature = trimmed; this.addContains(scopes[scopes.length - 1]!.id, node.id); if (kind === 'constant') { - const stringValueMatch = trimmed.match(/:=?\s*["']([^"']+)["']/); + const stringValueMatch = trimmed.match(/:=?\s*&?["']([^"']+)["']/); if (stringValueMatch) { const constName = varMatch[2]!; const stringValue = stringValueMatch[1]!; @@ -415,6 +415,8 @@ export class GDScriptExtractor { } private extractNodePathReferences(owner: string, code: string, lineNumber: number, scriptClass: Node | null, aliasOwner: string): void { + this.extractStringNodePathAlias(aliasOwner, code); + const shorthandRegex = /[$%]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/g; let shorthandMatch; while ((shorthandMatch = shorthandRegex.exec(code)) !== null) { @@ -433,12 +435,28 @@ export class GDScriptExtractor { this.addNodePathReference(owner, findChildMatch[1]!, lineNumber, findChildMatch.index, scriptClass); } + const findChildAliasRegex = /\bfind_child\s*\(\s*([A-Za-z_]\w*)\b/g; + let findChildAliasMatch; + while ((findChildAliasMatch = findChildAliasRegex.exec(code)) !== null) { + const nodePath = this.resolveStringAlias(aliasOwner, findChildAliasMatch[1]!); + if (!nodePath) continue; + this.addNodePathReference(owner, nodePath, lineNumber, findChildAliasMatch.index, scriptClass); + } + const projectFindNodeRegex = /\b_find_node\s*\(\s*[^,\n]+,\s*["']([^"']+)["']/g; let projectFindNodeMatch; while ((projectFindNodeMatch = projectFindNodeRegex.exec(code)) !== null) { this.addNodePathReference(owner, projectFindNodeMatch[1]!, lineNumber, projectFindNodeMatch.index, scriptClass); } + const projectFindNodeAliasRegex = /\b_find_node\s*\(\s*[^,\n]+,\s*([A-Za-z_]\w*)\b/g; + let projectFindNodeAliasMatch; + while ((projectFindNodeAliasMatch = projectFindNodeAliasRegex.exec(code)) !== null) { + const nodePath = this.resolveStringAlias(aliasOwner, projectFindNodeAliasMatch[1]!); + if (!nodePath) continue; + this.addNodePathReference(owner, nodePath, lineNumber, projectFindNodeAliasMatch.index, scriptClass); + } + const getNodeFormattedRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']*%d[^"']*)["']\s*%/g; let getNodeFormattedMatch; while ((getNodeFormattedMatch = getNodeFormattedRegex.exec(code)) !== null) { @@ -475,12 +493,37 @@ export class GDScriptExtractor { const argumentIndex = this.nodeLookupHelperArgumentIndex.get(helperName); if (argumentIndex === undefined) continue; const args = this.splitCallArguments(helperCallMatch[2]!); - const stringArg = args[argumentIndex]?.match(/^\s*["']([^"']+)["']\s*$/); - if (!stringArg) continue; - this.addNodePathReference(owner, stringArg[1]!, lineNumber, helperCallMatch.index, scriptClass); + const nodePath = this.resolveStringArgument(aliasOwner, args[argumentIndex]); + if (!nodePath) continue; + this.addNodePathReference(owner, nodePath, lineNumber, helperCallMatch.index, scriptClass); } } + private extractStringNodePathAlias(owner: string, code: string): void { + const stringAliasRegex = /\b(?:var|const)\s+([A-Za-z_]\w*)\s*(?::\s*[A-Za-z_]\w*)?\s*:=?\s*&?["']([^"']+)["']/g; + let stringAliasMatch; + while ((stringAliasMatch = stringAliasRegex.exec(code)) !== null) { + const value = stringAliasMatch[2]!; + if (this.isLikelyNodePath(value)) { + this.addNodePathAlias(owner, stringAliasMatch[1]!, value); + } + } + } + + private resolveStringArgument(owner: string, argument: string | undefined): string | null { + if (!argument) return null; + const literal = argument.match(/^\s*&?["']([^"']+)["']\s*$/); + if (literal) return literal[1]!; + + const identifier = argument.match(/^\s*([A-Za-z_]\w*)\s*$/); + if (!identifier) return null; + return this.resolveStringAlias(owner, identifier[1]!); + } + + private resolveStringAlias(owner: string, name: string): string | null { + return this.lookupNodePathAlias(owner, name) ?? this.stringConstants.get(name) ?? null; + } + private extractNodeLookupHelpers(functionScopes: FunctionScope[]): void { if (this.nodeLookupHelperArgumentIndex.size > 0) return; @@ -613,6 +656,10 @@ export class GDScriptExtractor { return /^[A-Z_][A-Za-z0-9_]*$/.test(value); } + private isLikelyNodePath(value: string): boolean { + return /^[A-Z_][A-Za-z0-9_]*(?:\/[A-Z_][A-Za-z0-9_]*)*$/.test(value); + } + private createDeclarationNode(kind: NodeKind, name: string, rawLine: string, line: number, indent: number): Node { const column = rawLine.indexOf(name); return this.createNode(kind, name, `${this.filePath}::${name}`, line, column < 0 ? indent : column, line, rawLine.length); From 1a18449c655aabaaa857a7ef17bcf6f2aa0c47ff Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 15:21:12 +0800 Subject: [PATCH 19/33] Report resolved edge counts after indexing --- __tests__/integration/full-pipeline.test.ts | 1 + src/index.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/__tests__/integration/full-pipeline.test.ts b/__tests__/integration/full-pipeline.test.ts index cb01aa5c7..5f80f0e2e 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/src/index.ts b/src/index.ts index 784bdbfad..355ae6038 100644 --- a/src/index.ts +++ b/src/index.ts @@ -353,6 +353,12 @@ export class CodeGraph { this.db.runMaintenance(); } + if (result.success && result.filesIndexed > 0) { + const stats = this.queries.getStats(); + result.nodesCreated = stats.nodeCount; + result.edgesCreated = stats.edgeCount; + } + return result; } finally { this.fileLock.release(); From 4e89228a3b3021b658a4950aa53a450724fcdee2 Mon Sep 17 00:00:00 2001 From: KirisamaMarisa <52296315+KirisamaMarisa@users.noreply.github.com> Date: Sun, 24 May 2026 15:33:33 +0800 Subject: [PATCH 20/33] Preserve Godot scene containment --- .cursor/rules/codegraph.mdc | 1 + __tests__/extraction.test.ts | 27 ++++++++++++++++++++++ src/extraction/godot-resource-extractor.ts | 3 +-- src/installer/instructions-template.ts | 1 + src/mcp/server-instructions.ts | 1 + 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc index 3f23cf6b6..a61c2128d 100644 --- a/.cursor/rules/codegraph.mdc +++ b/.cursor/rules/codegraph.mdc @@ -31,6 +31,7 @@ Use codegraph for **structural** questions — what calls what, what would break - **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. - **Don't loop `codegraph_node` over many symbols** — one `codegraph_explore` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more. - **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. +- **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/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index ff1c3c453..c7f0f33eb 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -325,6 +325,33 @@ script = ExtResource("1_script") 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] diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts index b54d912e8..36f27dbc0 100644 --- a/src/extraction/godot-resource-extractor.ts +++ b/src/extraction/godot-resource-extractor.ts @@ -196,8 +196,7 @@ export class GodotResourceExtractor { return; } - const parentPath = this.normalizeScenePath(parent || '.'); - const parentNode = parentPath === '.' ? this.rootNode : this.nodesByScenePath.get(parentPath); + const parentNode = this.resolveSceneNode(parent || '.'); this.addContains(parentNode?.id ?? fileNodeId, node.id); } diff --git a/src/installer/instructions-template.ts b/src/installer/instructions-template.ts index 10b6b7ca7..3e168971b 100644 --- a/src/installer/instructions-template.ts +++ b/src/installer/instructions-template.ts @@ -49,6 +49,7 @@ Use codegraph for **structural** questions — what calls what, what would break - **Don't chain \`codegraph_search\` + \`codegraph_node\`** when you just want context — \`codegraph_context\` is one call. - **Don't loop \`codegraph_node\` over many symbols** — one \`codegraph_explore\` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more. - **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. +- **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/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index d82a30911..3418a2c8a 100644 --- a/src/mcp/server-instructions.ts +++ b/src/mcp/server-instructions.ts @@ -51,6 +51,7 @@ of calls; a grep/read exploration is dozens. - **Onboarding**: \`codegraph_context\` first. If still unclear, \`codegraph_explore\` for breadth, then \`codegraph_node\` on specific symbols. - **Refactor planning**: \`codegraph_search\` → \`codegraph_callers\` → \`codegraph_impact\`. The blast-radius answer comes from impact, not from walking callers manually. - **Debugging a regression**: \`codegraph_callers\` of the suspected symbol; widen with \`codegraph_impact\` if an unexpected call appears. +- **Godot projects**: \`res://...\` resource paths and scene node names are valid symbols for callers/callees/impact queries. ## Anti-patterns From 2edca1e4b8d84df8d8d3c9ec6c34425e078f1dce Mon Sep 17 00:00:00 2001 From: nazgul Date: Tue, 23 Jun 2026 13:06:47 +0700 Subject: [PATCH 21/33] fix: resolve compilation errors in merge of PR #364 - Fix references to 'normalizedSymbol' -> 'symbol' (PR code leaked into HEAD path) - Fix references to 'referencePath' -> 'ref.referenceName' (PR code leaked into HEAD path) - Remove unused normalizePathReference function - Fix test assertions to match HEAD output format (StatusIconTemplate not _template) --- __tests__/resolution.test.ts | 4 ++-- src/mcp/tools.ts | 4 ++-- src/resolution/name-matcher.ts | 9 ++------- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index e2cd27c57..b933cf960 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -200,8 +200,8 @@ describe('Resolution Module', () => { projectPath: tempDir, }); - expect(templateResult.content[0]?.text ?? '').toContain('_template'); - expect(trackResult.content[0]?.text ?? '').toContain('_track'); + expect(templateResult.content[0]?.text ?? '').toContain('StatusIconTemplate'); + expect(trackResult.content[0]?.text ?? '').toContain('TrackPanel'); }); it('should include Godot scene instances when querying callers by instance node name', async () => { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 817245c27..02aaeba67 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -3663,8 +3663,8 @@ export class ToolHandler { // `stage_applyrun` and finds nothing. Re-search by the bare last part and // let `matchesSymbol` filter by qualifier. if (isQualified && results.length === 0) { - const tail = lastQualifierPart(normalizedSymbol); - if (tail && tail !== normalizedSymbol) results = cg.searchNodes(tail, { limit }); + const tail = lastQualifierPart(symbol); + if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit }); } if (results.length === 0) return []; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index be275f39c..891eb6ba3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -24,7 +24,7 @@ export function matchByFilePath( } // Extract the filename from the path - const fileName = referencePath.split('/').pop(); + const fileName = ref.referenceName.split('/').pop(); if (!fileName) return null; // Search for file nodes with this name @@ -34,7 +34,7 @@ export function matchByFilePath( if (fileNodes.length === 0) return null; // Prefer exact path match on qualified_name - const exactMatch = fileNodes.find(n => n.qualifiedName === referencePath || n.filePath === referencePath); + const exactMatch = fileNodes.find(n => n.qualifiedName === ref.referenceName || n.filePath === ref.referenceName); if (exactMatch) { return { original: ref, @@ -76,11 +76,6 @@ export function matchByFilePath( return null; } -function normalizePathReference(referenceName: string): string { - if (referenceName.startsWith('res://')) return referenceName.slice('res://'.length); - return referenceName; -} - /** * Among several file nodes that all match a bare include/import by basename, * pick the one closest to the referencing file: same directory first, then by From 4349105ade3b886e2953be788e21c6ac50d8394c Mon Sep 17 00:00:00 2001 From: nazgul Date: Mon, 27 Jul 2026 14:37:06 +0700 Subject: [PATCH 22/33] =?UTF-8?q?feat:=20add=20Godot=20dynamic=20pattern?= =?UTF-8?q?=20support=20=E2=80=94=20signal=20connections,=20%UniqueName=20?= =?UTF-8?q?refs,=20preload/load=20resolution,=20call()/call=5Fdeferred(),?= =?UTF-8?q?=20tween=20paths,=20@tool=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/extraction.test.ts | 94 +++++++++++++++++ __tests__/frameworks.test.ts | 117 ++++++++++++++++++++- src/extraction/gdscript-extractor.ts | 15 +++ src/extraction/godot-resource-extractor.ts | 34 ++++-- src/resolution/callback-synthesizer.ts | 29 ++++- src/resolution/frameworks/godot.ts | 87 +++++++++++++++ src/resolution/frameworks/index.ts | 3 + 7 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 src/resolution/frameworks/godot.ts diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 721de1dd9..08de47a7b 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -296,6 +296,46 @@ func setup() -> void: 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); + }); }); describe('Godot Resource Extraction', () => { @@ -379,6 +419,60 @@ card_id = &"knife" 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('TypeScript Extraction', () => { diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index ff1abb57b..2ea1f216d 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', () => { @@ -1669,3 +1669,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/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index fa5692b17..5c5b6a507 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -101,6 +101,9 @@ export class GDScriptExtractor { try { const fileNode = this.createFileNode(); const scriptClass = this.extractScriptClass(fileNode) ?? this.extractImplicitScriptClass(fileNode); + if (scriptClass && /^@tool\b/m.test(this.source)) { + scriptClass.decorators = ['tool']; + } this.extractDeclarations(fileNode, scriptClass); this.extractReferences(fileNode, scriptClass); } catch (error) { @@ -298,6 +301,18 @@ export class GDScriptExtractor { this.addReference(owner, resourceMatch[1]!, 'references', lineNumber, resourceMatch.index); } + const dynamicCallRegex = /\b(?:call|call_deferred)\s*\(\s*["']([A-Za-z_]\w*)["']/g; + let dynamicCallMatch; + while ((dynamicCallMatch = dynamicCallRegex.exec(code)) !== null) { + this.addReference(owner, dynamicCallMatch[1]!, 'calls', lineNumber, dynamicCallMatch.index); + } + + const tweenPathRegex = /\b(?:tween_property|tween_method|tween_value)\s*\(\s*[^,]+,\s*["']([^"']+)["']/g; + let tweenPathMatch; + while ((tweenPathMatch = tweenPathRegex.exec(code)) !== null) { + this.addReference(owner, tweenPathMatch[1]!, 'references', lineNumber, tweenPathMatch.index); + } + this.extractNodePathReferences(owner, code, lineNumber, scriptClass, functionOwner); this.extractSignalReferences(functionOwner, code, lineNumber); this.extractCallableReferences(functionOwner, code, lineNumber); diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts index 36f27dbc0..713f09837 100644 --- a/src/extraction/godot-resource-extractor.ts +++ b/src/extraction/godot-resource-extractor.ts @@ -16,6 +16,7 @@ export class GodotResourceExtractor { private errors: ExtractionError[] = []; private extResources = new Map(); private nodesByScenePath = new Map(); + private uniqueNameToNode = new Map(); private rootNode: Node | null = null; constructor(filePath: string, source: string) { @@ -157,20 +158,39 @@ export class GodotResourceExtractor { } const idMatch = line.match(/^\s*(id|content_id|card_id|relic_id|enemy_id|event_id|status_id|encounter_id|pool_id)\s*=\s*&?"([^"]+)"/); - if (!idMatch) return; + 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 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); + 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 fromNode = this.resolveSceneNode(attrs.get('from') || '.'); - const toNode = this.resolveSceneNode(attrs.get('to') || '.'); + 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)); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index ad3f61213..773d7e99e 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -37,6 +37,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 () @@ -278,7 +281,9 @@ function eventEmitterEdges(ctx: ResolutionContext): Edge[] { 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 = (idx: number) => content.slice(0, idx).split('\n').length; @@ -304,6 +309,28 @@ function eventEmitterEdges(ctx: ResolutionContext): Edge[] { 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[] = []; diff --git a/src/resolution/frameworks/godot.ts b/src/resolution/frameworks/godot.ts new file mode 100644 index 000000000..50164c3ae --- /dev/null +++ b/src/resolution/frameworks/godot.ts @@ -0,0 +1,87 @@ +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 result2 = tryResolveUniqueName(ref, context); + if (result2) return result2; + + const result3 = tryResolveGodotAlias(ref, context); + if (result3) return result3; + + return null; + }, +}; + +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; +} + +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 4fc3c3a5b..a8fc69004 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -26,6 +26,7 @@ import { swiftObjcBridgeResolver } from './swift-objc'; import { reactNativeBridgeResolver } from './react-native'; import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; +import { godotResolver } from './godot'; /** * All registered framework resolvers @@ -68,6 +69,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, ]; /** From 3e805cf954544d2de7ced6c5dcbffbac86dcd617 Mon Sep 17 00:00:00 2001 From: nazgul Date: Mon, 27 Jul 2026 14:37:49 +0700 Subject: [PATCH 23/33] chore: add .cate to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index da6c8ef6e..a0c347e32 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ tmux-web/ assets/__pycache__/ assets/generate-waitlist.py +.cate From eb18d8268b68cc7f6ff5f425eb4038cca6d9044e Mon Sep 17 00:00:00 2001 From: nazgul Date: Mon, 27 Jul 2026 15:07:27 +0700 Subject: [PATCH 24/33] feat: add Godot signal NodeKind, @export annotations, @onready $NodePath edges, add_to_group/remove_from_group, uid:// parsing, scene inheritance edges, codegraph_references tool, LimboAI detection --- __tests__/extraction.test.ts | 2 +- __tests__/mcp-tool-allowlist.test.ts | 9 +- src/extraction/gdscript-extractor.ts | 18 +++- src/extraction/godot-resource-extractor.ts | 20 ++++ src/mcp/tools.ts | 102 ++++++++++++++++++++- src/resolution/frameworks/godot.ts | 50 ++++++++++ src/types.ts | 1 + 7 files changed, 194 insertions(+), 8 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 08de47a7b..91cf18fb0 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -217,7 +217,7 @@ func _find_label(label_name: String) -> Label: expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'sprite')).toBe(true); expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'move_ratio')).toBe(true); expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'shared_counter')).toBe(true); - expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'health_changed')).toBe(true); + expect(result.nodes.some((n) => n.kind === '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); diff --git a/__tests__/mcp-tool-allowlist.test.ts b/__tests__/mcp-tool-allowlist.test.ts index 08067c918..fbee01c46 100644 --- a/__tests__/mcp-tool-allowlist.test.ts +++ b/__tests__/mcp-tool-allowlist.test.ts @@ -17,16 +17,17 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => { const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort(); - it('exposes the default 4-tool surface when unset', () => { + it('exposes the default 5-tool surface when unset', () => { delete process.env[ENV]; // The default set (see DEFAULT_MCP_TOOLS): explore + node are the // validated workhorses, search the cheap lookup, callers the one - // irreplaceable enumerator. callees/impact/files/status stay defined - // and executable but unlisted — impact appeared in ZERO recorded runs. + // irreplaceable enumerator, references the cross-ref finder. + // callees/impact/files/status stay defined and executable but unlisted. expect(listed()).toEqual([ 'codegraph_callers', 'codegraph_explore', 'codegraph_node', + 'codegraph_references', 'codegraph_search', ]); }); @@ -48,7 +49,7 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => { it('treats an empty/whitespace value as unset (default surface)', () => { process.env[ENV] = ' '; - expect(listed()).toHaveLength(4); + expect(listed()).toHaveLength(5); expect(listed()).toContain('codegraph_explore'); }); diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts index 5c5b6a507..119cc81a8 100644 --- a/src/extraction/gdscript-extractor.ts +++ b/src/extraction/gdscript-extractor.ts @@ -204,7 +204,7 @@ export class GDScriptExtractor { const signalMatch = trimmed.match(/^signal\s+([A-Za-z_]\w*)/); if (signalMatch) { - const node = this.createDeclarationNode('function', signalMatch[1]!, rawLine, lineNumber, indent); + const node = this.createDeclarationNode('signal', signalMatch[1]!, rawLine, lineNumber, indent); node.signature = trimmed; this.addContains(scopes[scopes.length - 1]!.id, node.id); continue; @@ -226,6 +226,10 @@ export class GDScriptExtractor { const kind: NodeKind = varMatch[1] === 'const' ? 'constant' : 'variable'; const node = this.createDeclarationNode(kind, varMatch[2]!, rawLine, lineNumber, indent); node.signature = trimmed; + const exportAnn = rawLine.match(/@export(?:_(\w+))?(?:\(([^)]*)\))?/); + if (exportAnn) { + node.decorators = [exportAnn[1] ? `export_${exportAnn[1]}` : 'export']; + } this.addContains(scopes[scopes.length - 1]!.id, node.id); if (kind === 'constant') { const stringValueMatch = trimmed.match(/:=?\s*&?["']([^"']+)["']/); @@ -238,6 +242,12 @@ export class GDScriptExtractor { } } } + if (rawLine.includes('@onready')) { + const onreadyPath = rawLine.match(/[$]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/); + if (onreadyPath) { + this.addReference(node.id, onreadyPath[1]!, 'references', lineNumber, rawLine.indexOf('$')); + } + } } const dynamicNodeNameMatch = trimmed.match(/\b[A-Za-z_]\w*\s*\.\s*name\s*=\s*["']([A-Za-z_]\w*)["']/); @@ -307,6 +317,12 @@ export class GDScriptExtractor { this.addReference(owner, dynamicCallMatch[1]!, 'calls', lineNumber, dynamicCallMatch.index); } + const groupRegex = /\b(?:add_to_group|remove_from_group)\s*\(\s*["']([^"']+)["']/g; + let groupMatch; + while ((groupMatch = groupRegex.exec(code)) !== null) { + this.addReference(owner, groupMatch[1]!, 'references', lineNumber, groupMatch.index); + } + const tweenPathRegex = /\b(?:tween_property|tween_method|tween_value)\s*\(\s*[^,]+,\s*["']([^"']+)["']/g; let tweenPathMatch; while ((tweenPathMatch = tweenPathRegex.exec(code)) !== null) { diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts index 713f09837..439821107 100644 --- a/src/extraction/godot-resource-extractor.ts +++ b/src/extraction/godot-resource-extractor.ts @@ -15,6 +15,7 @@ export class GodotResourceExtractor { 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; @@ -97,6 +98,8 @@ export class GodotResourceExtractor { 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); @@ -144,6 +147,15 @@ export class GodotResourceExtractor { 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 { @@ -256,6 +268,14 @@ export class GodotResourceExtractor { 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 { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 02aaeba67..853ca76be 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -85,7 +85,7 @@ 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. */ @@ -542,6 +542,34 @@ export const tools: ToolDefinition[] = [ required: [], }, }, + { + 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'], + }, + }, { 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.', @@ -644,7 +672,7 @@ export function getStaticTools(): ToolDefinition[] { * caller with file:line, callback registrations labeled, one section per * same-named definition) is the one job explore/node don't replicate. */ -const DEFAULT_MCP_TOOLS = new Set(['explore', 'node', 'search', 'callers']); +const DEFAULT_MCP_TOOLS = new Set(['explore', 'node', 'search', 'callers', 'references']); /** * Tool handler that executes tools against a CodeGraph instance @@ -1117,6 +1145,8 @@ export class ToolHandler { result = await this.handleCallees(args); break; case 'codegraph_impact': result = await this.handleImpact(args); break; + case 'codegraph_references': + result = await this.handleReferences(args); break; case 'codegraph_explore': result = await this.handleExplore(args); break; case 'codegraph_node': @@ -1315,6 +1345,74 @@ export class ToolHandler { && (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 */ diff --git a/src/resolution/frameworks/godot.ts b/src/resolution/frameworks/godot.ts index 50164c3ae..e211a7b8b 100644 --- a/src/resolution/frameworks/godot.ts +++ b/src/resolution/frameworks/godot.ts @@ -20,8 +20,18 @@ export const godotResolver: FrameworkResolver = { 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 { @@ -67,6 +77,46 @@ function tryResolveUniqueName(ref: UnresolvedRef, context: ResolutionContext): R 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; diff --git a/src/types.ts b/src/types.ts index 038f76acc..1e9a06df4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,7 @@ export const NODE_KINDS = [ 'export', 'route', 'component', + 'signal', ] as const; export type NodeKind = (typeof NODE_KINDS)[number]; From a22576cb86f7b89afd396754a1ce84083d3b29e4 Mon Sep 17 00:00:00 2001 From: nazgul Date: Sat, 22 Aug 2026 11:46:53 +0700 Subject: [PATCH 25/33] gdscript: vendor tree-sitter-gdscript v6.1.0 wasm (ABI 14) --- src/extraction/wasm/tree-sitter-gdscript.wasm | Bin 0 -> 289759 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100755 src/extraction/wasm/tree-sitter-gdscript.wasm diff --git a/src/extraction/wasm/tree-sitter-gdscript.wasm b/src/extraction/wasm/tree-sitter-gdscript.wasm new file mode 100755 index 0000000000000000000000000000000000000000..491ff5e65400f9f96d12079e16f1e5670cecb71d GIT binary patch literal 289759 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 literal 0 HcmV?d00001 From 1a4681da41bed57d2522ff4eb26b7a03d6a3b6cc Mon Sep 17 00:00:00 2001 From: nazgul Date: Sat, 22 Aug 2026 12:12:24 +0700 Subject: [PATCH 26/33] gdscript: add AST-driven declarative extractor (tree-sitter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symbols/scopes/signatures from the vendored grammar; Godot reference passes (node paths, signal wiring, Callable targets, const-string propagation) ported line-scans preserving the previous output contract. Not yet routed — wiring lands in the next commit. --- src/extraction/languages/gdscript.ts | 865 +++++++++++++++++++++++++++ 1 file changed, 865 insertions(+) create mode 100644 src/extraction/languages/gdscript.ts diff --git a/src/extraction/languages/gdscript.ts b/src/extraction/languages/gdscript.ts new file mode 100644 index 000000000..82cb78b98 --- /dev/null +++ b/src/extraction/languages/gdscript.ts @@ -0,0 +1,865 @@ +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; + + 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': + 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: + return false; + } +} + +function dispatchChildren(parent: SyntaxNode, ctx: ExtractorContext, state: ExtractorState): void { + for (const child of childrenOf(parent)) { + dispatch(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; + const name = 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'); + + const method = ctx.createNode('method', 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; + + const extendsMatch = code.match(/^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:(?:class_name|class)\s+[A-Za-z_]\w*\s+)?extends\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\w.]*))/); + if (extendsMatch) { + emitRef(ctx, owner, extendsMatch[1] || extendsMatch[2] || extendsMatch[3]!, 'extends', lineNumber, code.indexOf('extends'), state); + } + + 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); + } + + 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]!); +} From f935af343344d4ee7001eef51ada51d7e32672d3 Mon Sep 17 00:00:00 2001 From: nazgul Date: Sat, 22 Aug 2026 12:12:24 +0700 Subject: [PATCH 27/33] gdscript: route .gd files through the tree-sitter pipeline Register the declarative extractor in EXTRACTORS, treat gdscript as a vendored GrammarLanguage (ABI-14 wasm), and drop the custom-extractor routing branch. godot_resource (.tscn/.tres) keeps its custom extractor. --- src/extraction/grammars.ts | 10 +++++----- src/extraction/languages/index.ts | 2 ++ src/extraction/tree-sitter.ts | 5 ----- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index fbf17f73c..7f849ee2f 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import { Parser, Language as WasmLanguage } from 'web-tree-sitter'; import { Language } from '../types'; -export type GrammarLanguage = Exclude; +export type GrammarLanguage = Exclude; /** * WASM filename map — maps each language to its .wasm grammar file @@ -39,6 +39,7 @@ const WASM_GRAMMAR_FILES: Record = { r: 'tree-sitter-r.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + gdscript: 'tree-sitter-gdscript.wasm', }; /** @@ -221,7 +222,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise> = { typescript: typescriptExtractor, @@ -51,4 +52,5 @@ export const EXTRACTORS: Partial> = { r: rExtractor, luau: luauExtractor, objc: objcExtractor, + gdscript: gdscriptExtractor, }; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index ebd98f44f..9ac723e5c 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -27,7 +27,6 @@ import { SvelteExtractor } from './svelte-extractor'; import { AstroExtractor } from './astro-extractor'; import { DfmExtractor } from './dfm-extractor'; import { VueExtractor } from './vue-extractor'; -import { GDScriptExtractor } from './gdscript-extractor'; import { GodotResourceExtractor } from './godot-resource-extractor'; import { MyBatisExtractor } from './mybatis-extractor'; import { @@ -4772,10 +4771,6 @@ 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 === 'gdscript') { - // Use custom extractor for GDScript - const extractor = new GDScriptExtractor(filePath, source); - result = extractor.extract(); } else if (detectedLanguage === 'godot_resource') { // Use custom extractor for Godot text scenes/resources const extractor = new GodotResourceExtractor(filePath, source); From 36448b70528c6c14391d89422c80b2cbd3db0f1f Mon Sep 17 00:00:00 2001 From: nazgul Date: Sat, 22 Aug 2026 12:18:24 +0700 Subject: [PATCH 28/33] test(gdscript): cover statics, lambdas, match arms, enum members, inner classes New AST-only capabilities: static flags, lambda bodies produce no symbol nodes while their calls still resolve, enum_member nodes (additive), and no symbol extraction from code-like text inside strings. --- __tests__/extraction.test.ts | 96 ++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 91cf18fb0..bfea17c63 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -336,6 +336,102 @@ func _ready() -> void: 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); + }); }); describe('Godot Resource Extraction', () => { From c7c83b7ec9e9fee6fc4ae56cb5726c82a214a669 Mon Sep 17 00:00:00 2001 From: nazgul Date: Sat, 22 Aug 2026 12:52:42 +0700 Subject: [PATCH 29/33] gdscript: fix parity gaps found by real-repo diff vs old extractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - descend into unclaimed compound statements (locals nested in if/for/match bodies were dropped; beehave -727 variables) - constructor_definition: func _init() parsed as its own node type — extract as method '_init' (121 files missed it) - funcs in scripts without class_name/extends keep kind 'function' - emit extends targets only from the AST walk; the line-pass regex double-counted every extends (+160) - inline 'class_name X extends Y': extends nests inside class_name_statement — emit from the root scan --- src/extraction/languages/gdscript.ts | 37 ++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/extraction/languages/gdscript.ts b/src/extraction/languages/gdscript.ts index 82cb78b98..f0bf2e4dd 100644 --- a/src/extraction/languages/gdscript.ts +++ b/src/extraction/languages/gdscript.ts @@ -269,6 +269,18 @@ function extractFile(root: SyntaxNode, ctx: ExtractorContext, state: ExtractorSt 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); @@ -336,6 +348,7 @@ function dispatch(node: SyntaxNode, ctx: ExtractorContext, state: ExtractorState return extractVariableDeclaration(node, ctx, state, 'variable'); case 'function_definition': + case 'constructor_definition': // `func _init(...)` — Godot's constructor return extractFunctionDefinition(node, ctx, state); case 'lambda': { @@ -367,13 +380,17 @@ function dispatch(node: SyntaxNode, ctx: ExtractorContext, state: ExtractorState } 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)) { - dispatch(child, ctx, state); + const handled = dispatch(child, ctx, state); + if (!handled) dispatchChildren(child, ctx, state); } } @@ -411,12 +428,19 @@ function emitRef( function extractFunctionDefinition(node: SyntaxNode, ctx: ExtractorContext, state: ExtractorState): boolean { const source = ctx.source; - const name = textOfField(node, 'name', 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'); - const method = ctx.createNode('method', name, node, { + // 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, }); @@ -790,10 +814,9 @@ function runReferencePasses(ctx: ExtractorContext, state: ExtractorState): void let match: RegExpExecArray | null; - const extendsMatch = code.match(/^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:(?:class_name|class)\s+[A-Za-z_]\w*\s+)?extends\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\w.]*))/); - if (extendsMatch) { - emitRef(ctx, owner, extendsMatch[1] || extendsMatch[2] || extendsMatch[3]!, 'extends', lineNumber, code.indexOf('extends'), state); - } + // 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) { From 57323f612bc777184a5a9ccff4edffe1da9254a5 Mon Sep 17 00:00:00 2001 From: nazgul Date: Sat, 22 Aug 2026 12:54:03 +0700 Subject: [PATCH 30/33] gdscript: retire the line-regex GDScriptExtractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Superseded by the tree-sitter declarative extractor. Parity proven on beehave (322 .gd) and dialogic (270 .gd): node/edge counts within ±0.4%, references identical, +enum_member coverage, and the old extractor's phantom-method false positives inside code-template strings are gone. --- src/extraction/gdscript-extractor.ts | 799 --------------------------- 1 file changed, 799 deletions(-) delete mode 100644 src/extraction/gdscript-extractor.ts diff --git a/src/extraction/gdscript-extractor.ts b/src/extraction/gdscript-extractor.ts deleted file mode 100644 index 119cc81a8..000000000 --- a/src/extraction/gdscript-extractor.ts +++ /dev/null @@ -1,799 +0,0 @@ -import * as path from 'path'; -import { Edge, ExtractionError, ExtractionResult, Node, NodeKind, UnresolvedReference } from '../types'; -import { generateNodeId } from './tree-sitter-helpers'; - -interface Scope { - id: string; - indent: number; - kind: NodeKind; -} - -interface FunctionScope extends Scope { - startLine: number; -} - -const KEYWORDS = new Set([ - 'if', - 'elif', - 'for', - 'while', - 'match', - 'return', - 'await', - 'assert', - 'print', - 'push_error', - 'push_warning', - 'preload', - 'load', - 'super', - 'func', - 'signal', -]); - -const ANNOTATION_PREFIX = '(?:(?:@\\w+(?:\\([^)]*\\))?)\\s+)*'; - -const GODOT_BUILT_IN_CALLS = new Set([ - 'AABB', - 'Array', - 'Basis', - 'Callable', - 'Color', - 'Dictionary', - 'NodePath', - 'PackedByteArray', - 'PackedColorArray', - 'PackedFloat32Array', - 'PackedFloat64Array', - 'PackedInt32Array', - 'PackedInt64Array', - 'PackedScene', - 'PackedStringArray', - 'PackedVector2Array', - 'PackedVector3Array', - 'Plane', - 'Projection', - 'Quaternion', - 'Rect2', - 'Rect2i', - 'RID', - 'Signal', - 'String', - 'StringName', - 'Transform2D', - 'Transform3D', - 'Vector2', - 'Vector2i', - 'Vector3', - 'Vector3i', - 'Vector4', - 'Vector4i', -]); - -/** - * Lightweight GDScript extractor. - * - * This intentionally avoids a hard dependency on a GDScript WASM grammar while - * still giving Godot projects useful symbol search and reference edges. - */ -export class GDScriptExtractor { - private filePath: string; - private source: string; - private lines: string[]; - private nodes: Node[] = []; - private edges: Edge[] = []; - private unresolvedReferences: UnresolvedReference[] = []; - private errors: ExtractionError[] = []; - private stringConstants = new Map(); - private dynamicNodeNames = new Set(); - private nodePathAliases = new Map>(); - private nodeLookupHelperArgumentIndex = new Map(); - - constructor(filePath: string, source: string) { - this.filePath = filePath; - this.source = source; - this.lines = source.split('\n'); - } - - extract(): ExtractionResult { - const startTime = Date.now(); - - try { - const fileNode = this.createFileNode(); - const scriptClass = this.extractScriptClass(fileNode) ?? this.extractImplicitScriptClass(fileNode); - if (scriptClass && /^@tool\b/m.test(this.source)) { - scriptClass.decorators = ['tool']; - } - this.extractDeclarations(fileNode, scriptClass); - this.extractReferences(fileNode, scriptClass); - } catch (error) { - this.errors.push({ - message: `GDScript extraction error: ${error instanceof Error ? error.message : String(error)}`, - filePath: this.filePath, - severity: 'error', - code: 'parse_error', - }); - } - - return { - nodes: this.nodes, - edges: this.edges, - unresolvedReferences: this.unresolvedReferences, - errors: this.errors, - durationMs: Date.now() - startTime, - }; - } - - private createFileNode(): Node { - const node: Node = { - id: `file:${this.filePath}`, - kind: 'file', - name: path.basename(this.filePath), - qualifiedName: this.filePath, - filePath: this.filePath, - language: 'gdscript', - startLine: 1, - endLine: this.lines.length, - startColumn: 0, - endColumn: this.lines[this.lines.length - 1]?.length ?? 0, - updatedAt: Date.now(), - }; - this.nodes.push(node); - return node; - } - - private extractScriptClass(fileNode: Node): Node | null { - const classNameMatch = this.source.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}class_name\\s+([A-Za-z_]\\w*)`, 'm')); - if (!classNameMatch) return null; - - const index = classNameMatch.index ?? 0; - const line = this.getLineNumber(index); - const column = index - this.getLineStart(line) + classNameMatch[0].indexOf(classNameMatch[1]!); - const name = classNameMatch[1]!; - const node = this.createNode('class', name, `${this.filePath}::${name}`, line, column, line, column + classNameMatch[0].trimEnd().length); - this.addContains(fileNode.id, node.id); - return node; - } - - private extractImplicitScriptClass(fileNode: Node): Node | null { - const extendsMatch = this.source.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}extends\\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\\w.]*))`, 'm')); - if (!extendsMatch) return null; - - const index = extendsMatch.index ?? 0; - const line = this.getLineNumber(index); - const name = this.scriptClassNameFromPath(); - const column = index - this.getLineStart(line); - const node = this.createNode('class', name, `${this.filePath}::${name}`, line, column, line, column + (this.lines[line - 1]?.trimEnd().length ?? 0)); - node.signature = `implicit script class extends ${extendsMatch[1] || extendsMatch[2] || extendsMatch[3]}`; - this.addContains(fileNode.id, node.id); - return node; - } - - private extractDeclarations(fileNode: Node, scriptClass: Node | null): void { - const scopes: Scope[] = [{ id: scriptClass?.id ?? fileNode.id, indent: -1, kind: scriptClass ? 'class' : 'file' }]; - - for (let i = 0; i < this.lines.length; i++) { - const lineNumber = i + 1; - const rawLine = this.lines[i] ?? ''; - const code = this.stripComment(rawLine); - if (!code.trim()) continue; - - const indent = this.indentOf(rawLine); - while (scopes.length > 1 && indent <= scopes[scopes.length - 1]!.indent) { - scopes.pop(); - } - - const trimmed = code.trim(); - if (new RegExp(`^${ANNOTATION_PREFIX}class_name\\s+`).test(trimmed)) continue; - - const classMatch = trimmed.match(new RegExp(`^${ANNOTATION_PREFIX}class\\s+([A-Za-z_]\\w*)\\s*(?:extends\\s+[^:]+)?\\s*:?`)); - if (classMatch) { - const node = this.createDeclarationNode('class', classMatch[1]!, rawLine, lineNumber, indent); - this.addContains(scopes[scopes.length - 1]!.id, node.id); - scopes.push({ id: node.id, indent, kind: 'class' }); - continue; - } - - const enumMatch = trimmed.match(/^enum(?:\s+([A-Za-z_]\w*))?/); - if (enumMatch) { - const name = enumMatch[1] || ''; - const node = this.createDeclarationNode('enum', name, rawLine, lineNumber, indent); - this.addContains(scopes[scopes.length - 1]!.id, node.id); - continue; - } - - const signalMatch = trimmed.match(/^signal\s+([A-Za-z_]\w*)/); - if (signalMatch) { - const node = this.createDeclarationNode('signal', signalMatch[1]!, rawLine, lineNumber, indent); - node.signature = trimmed; - this.addContains(scopes[scopes.length - 1]!.id, node.id); - continue; - } - - const funcMatch = trimmed.match(new RegExp(`^${ANNOTATION_PREFIX}(?:static\\s+)?func\\s+([A-Za-z_]\\w*)\\s*(\\([^)]*\\))?(?:\\s*->\\s*([^:]+))?`)); - if (funcMatch) { - const insideClass = scopes.some((scope) => scope.kind === 'class'); - const node = this.createDeclarationNode(insideClass ? 'method' : 'function', funcMatch[1]!, rawLine, lineNumber, indent); - node.signature = `${funcMatch[2] || '()'}${funcMatch[3] ? ` -> ${funcMatch[3].trim()}` : ''}`; - node.isStatic = /\bstatic\s+func\b/.test(trimmed); - this.addContains(scopes[scopes.length - 1]!.id, node.id); - scopes.push({ id: node.id, indent, kind: node.kind }); - continue; - } - - const varMatch = trimmed.match(new RegExp(`^${ANNOTATION_PREFIX}(?:static\\s+)?(var|const)\\s+([A-Za-z_]\\w*)`)); - if (varMatch) { - const kind: NodeKind = varMatch[1] === 'const' ? 'constant' : 'variable'; - const node = this.createDeclarationNode(kind, varMatch[2]!, rawLine, lineNumber, indent); - node.signature = trimmed; - const exportAnn = rawLine.match(/@export(?:_(\w+))?(?:\(([^)]*)\))?/); - if (exportAnn) { - node.decorators = [exportAnn[1] ? `export_${exportAnn[1]}` : 'export']; - } - this.addContains(scopes[scopes.length - 1]!.id, node.id); - if (kind === 'constant') { - const stringValueMatch = trimmed.match(/:=?\s*&?["']([^"']+)["']/); - if (stringValueMatch) { - const constName = varMatch[2]!; - const stringValue = stringValueMatch[1]!; - this.stringConstants.set(constName, stringValue); - if (/_NAME$/.test(constName) && this.isSimpleNodeName(stringValue)) { - this.addDynamicNodeNameDeclaration(stringValue, rawLine, trimmed, lineNumber, scopes[scopes.length - 1]!.id); - } - } - } - if (rawLine.includes('@onready')) { - const onreadyPath = rawLine.match(/[$]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/); - if (onreadyPath) { - this.addReference(node.id, onreadyPath[1]!, 'references', lineNumber, rawLine.indexOf('$')); - } - } - } - - const dynamicNodeNameMatch = trimmed.match(/\b[A-Za-z_]\w*\s*\.\s*name\s*=\s*["']([A-Za-z_]\w*)["']/); - if (dynamicNodeNameMatch) { - this.addDynamicNodeNameDeclaration(dynamicNodeNameMatch[1]!, rawLine, trimmed, lineNumber, scopes[scopes.length - 1]!.id); - } - - const formattedNodeName = this.extractFormattedNodePathBase(trimmed); - if (formattedNodeName) { - this.addDynamicNodeNameDeclaration(formattedNodeName, rawLine, trimmed, lineNumber, scopes[scopes.length - 1]!.id); - } - } - } - - private extractReferences(fileNode: Node, scriptClass: Node | null): void { - const functionScopes = this.nodes - .filter((node) => (node.kind === 'function' || node.kind === 'method') && node.language === 'gdscript') - .map((node) => ({ id: node.id, indent: this.indentOf(this.lines[node.startLine - 1] ?? ''), kind: node.kind, startLine: node.startLine } as FunctionScope)) - .sort((a, b) => a.startLine - b.startLine); - this.extractNodeLookupHelpers(functionScopes); - const declarationByLine = new Map(); - for (const node of this.nodes) { - if ((node.kind === 'variable' || node.kind === 'constant') && node.language === 'gdscript') { - declarationByLine.set(node.startLine, node); - } - } - - const functionOwnerForLine = (line: number, indent: number): string => { - let owner = scriptClass?.id ?? fileNode.id; - for (const scope of functionScopes) { - if (scope.startLine < line && scope.indent < indent) { - owner = scope.id; - } - } - return owner; - }; - - const ownerForLine = (line: number, indent: number): string => { - const sameLineDeclaration = declarationByLine.get(line); - if (sameLineDeclaration) return sameLineDeclaration.id; - - return functionOwnerForLine(line, indent); - }; - - for (let i = 0; i < this.lines.length; i++) { - const lineNumber = i + 1; - const rawLine = this.lines[i] ?? ''; - const code = this.stripComment(rawLine); - const indent = this.indentOf(rawLine); - const owner = ownerForLine(lineNumber, indent); - const functionOwner = functionOwnerForLine(lineNumber, indent); - - const extendsMatch = code.match(new RegExp(`^\\s*${ANNOTATION_PREFIX}(?:(?:class_name|class)\\s+[A-Za-z_]\\w*\\s+)?extends\\s+(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\\w.]*))`)); - if (extendsMatch) { - this.addReference(owner, extendsMatch[1] || extendsMatch[2] || extendsMatch[3]!, 'extends', lineNumber, code.indexOf('extends')); - } - - const resourceRegex = /\b(?:preload|load)\s*\(\s*["']([^"']+)["']\s*\)/g; - let resourceMatch; - while ((resourceMatch = resourceRegex.exec(code)) !== null) { - this.addReference(owner, resourceMatch[1]!, 'references', lineNumber, resourceMatch.index); - } - - const dynamicCallRegex = /\b(?:call|call_deferred)\s*\(\s*["']([A-Za-z_]\w*)["']/g; - let dynamicCallMatch; - while ((dynamicCallMatch = dynamicCallRegex.exec(code)) !== null) { - this.addReference(owner, dynamicCallMatch[1]!, 'calls', lineNumber, dynamicCallMatch.index); - } - - const groupRegex = /\b(?:add_to_group|remove_from_group)\s*\(\s*["']([^"']+)["']/g; - let groupMatch; - while ((groupMatch = groupRegex.exec(code)) !== null) { - this.addReference(owner, groupMatch[1]!, 'references', lineNumber, groupMatch.index); - } - - const tweenPathRegex = /\b(?:tween_property|tween_method|tween_value)\s*\(\s*[^,]+,\s*["']([^"']+)["']/g; - let tweenPathMatch; - while ((tweenPathMatch = tweenPathRegex.exec(code)) !== null) { - this.addReference(owner, tweenPathMatch[1]!, 'references', lineNumber, tweenPathMatch.index); - } - - this.extractNodePathReferences(owner, code, lineNumber, scriptClass, functionOwner); - this.extractSignalReferences(functionOwner, code, lineNumber); - this.extractCallableReferences(functionOwner, code, lineNumber); - - const memberCallRegex = /(?:\b([A-Za-z_]\w*)|([$%][A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*))\s*\.\s*([A-Za-z_]\w*)\s*\(/g; - let memberCallMatch; - while ((memberCallMatch = memberCallRegex.exec(code)) !== null) { - const receiver = memberCallMatch[1] || this.nodePathReceiverName(memberCallMatch[2]!); - const method = memberCallMatch[3]!; - if (KEYWORDS.has(method)) continue; - this.addReference(owner, `${receiver}.${method}`, 'calls', lineNumber, memberCallMatch.index); - } - - const callRegex = /\b([A-Za-z_]\w*)\s*\(/g; - let callMatch; - while ((callMatch = callRegex.exec(code)) !== null) { - const name = callMatch[1]!; - const prefix = code.slice(Math.max(0, callMatch.index - 8), callMatch.index); - if ( - KEYWORDS.has(name) || - GODOT_BUILT_IN_CALLS.has(name) || - /\.\s*$/.test(prefix) || - /\bfunc\s+$/.test(prefix) || - /\bsignal\s+$/.test(prefix) - ) continue; - this.addReference(owner, name, 'calls', lineNumber, callMatch.index); - } - } - } - - private extractSignalReferences(owner: string, code: string, lineNumber: number): void { - this.extractSignalConnectReferences(owner, code, lineNumber); - this.extractSignalEmitReferences(owner, code, lineNumber); - } - - private extractSignalConnectReferences(owner: string, code: string, lineNumber: number): void { - const memberConnectRegex = /\b(?:([A-Za-z_]\w*)|([$%][A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*))\s*\.\s*([A-Za-z_]\w*)\s*\.\s*connect\s*\(/g; - let memberConnectMatch; - while ((memberConnectMatch = memberConnectRegex.exec(code)) !== null) { - const receiver = memberConnectMatch[1] || this.nodePathReceiverName(memberConnectMatch[2]!); - const signalName = memberConnectMatch[3]!; - this.addReference(owner, signalName, 'references', lineNumber, memberConnectMatch.index); - this.addReference(owner, `${receiver}.${signalName}`, 'references', lineNumber, memberConnectMatch.index); - - const argsStart = memberConnectRegex.lastIndex; - const argsEnd = this.findCallEnd(code, argsStart - 1); - if (argsEnd > argsStart) { - this.addCallableTargetReferences(owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); - } - } - - const bareConnectRegex = /\b([A-Za-z_]\w*)\s*\.\s*connect\s*\(/g; - let bareConnectMatch; - while ((bareConnectMatch = bareConnectRegex.exec(code)) !== null) { - const signalName = bareConnectMatch[1]!; - if (signalName === 'node') continue; - this.addReference(owner, signalName, 'references', lineNumber, bareConnectMatch.index); - - const argsStart = bareConnectRegex.lastIndex; - const argsEnd = this.findCallEnd(code, argsStart - 1); - if (argsEnd > argsStart) { - this.addCallableTargetReferences(owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); - } - } - - const legacyConnectRegex = /\bconnect\s*\(\s*(?:&)?["']([^"']+)["']\s*,/g; - let legacyConnectMatch; - while ((legacyConnectMatch = legacyConnectRegex.exec(code)) !== null) { - this.addReference(owner, legacyConnectMatch[1]!, 'references', lineNumber, legacyConnectMatch.index); - - const argsStart = legacyConnectRegex.lastIndex; - const argsEnd = this.findCallEnd(code, code.indexOf('(', legacyConnectMatch.index)); - if (argsEnd > argsStart) { - this.addCallableTargetReferences(owner, code.slice(argsStart, argsEnd), lineNumber, argsStart); - } - } - } - - private extractSignalEmitReferences(owner: string, code: string, lineNumber: number): void { - const memberEmitRegex = /\b([A-Za-z_]\w*)\s*\.\s*emit\s*\(/g; - let memberEmitMatch; - while ((memberEmitMatch = memberEmitRegex.exec(code)) !== null) { - this.addReference(owner, memberEmitMatch[1]!, 'calls', lineNumber, memberEmitMatch.index); - } - - const emitSignalRegex = /\bemit_signal\s*\(\s*(?:&)?["']([^"']+)["']/g; - let emitSignalMatch; - while ((emitSignalMatch = emitSignalRegex.exec(code)) !== null) { - this.addReference(owner, emitSignalMatch[1]!, 'calls', lineNumber, emitSignalMatch.index); - } - } - - private addCallableTargetReferences(owner: string, args: string, lineNumber: number, argsColumn: number): void { - const callableRegex = /\bCallable\s*\(\s*(?:self|this|[A-Za-z_]\w*)\s*,\s*["']([A-Za-z_]\w*)["']\s*\)/g; - let callableMatch; - while ((callableMatch = callableRegex.exec(args)) !== null) { - this.addReference(owner, callableMatch[1]!, 'calls', lineNumber, argsColumn + callableMatch.index); - } - - const directHandlerMatch = args.match(/^\s*([A-Za-z_]\w*)\b/); - if (directHandlerMatch) { - const name = directHandlerMatch[1]!; - if (!KEYWORDS.has(name) && !GODOT_BUILT_IN_CALLS.has(name) && name !== 'func') { - this.addReference(owner, name, 'calls', lineNumber, argsColumn + args.indexOf(name)); - } - } - } - - private extractCallableReferences(owner: string, code: string, lineNumber: number): void { - const callableRegex = /\bCallable\s*\(\s*(?:self|this|[A-Za-z_]\w*)\s*,\s*["']([A-Za-z_]\w*)["']\s*\)/g; - let callableMatch; - while ((callableMatch = callableRegex.exec(code)) !== null) { - this.addReference(owner, callableMatch[1]!, 'calls', lineNumber, callableMatch.index); - } - } - - private extractNodePathReferences(owner: string, code: string, lineNumber: number, scriptClass: Node | null, aliasOwner: string): void { - this.extractStringNodePathAlias(aliasOwner, code); - - const shorthandRegex = /[$%]([A-Za-z_]\w*(?:\/[A-Za-z_]\w*)*)/g; - let shorthandMatch; - while ((shorthandMatch = shorthandRegex.exec(code)) !== null) { - this.addNodePathReference(owner, shorthandMatch[1]!, lineNumber, shorthandMatch.index, scriptClass); - } - - const getNodeRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']+)["']\s*\)/g; - let getNodeMatch; - while ((getNodeMatch = getNodeRegex.exec(code)) !== null) { - this.addNodePathReference(owner, getNodeMatch[1]!, lineNumber, getNodeMatch.index, scriptClass); - } - - const findChildRegex = /\bfind_child\s*\(\s*["']([^"']+)["']/g; - let findChildMatch; - while ((findChildMatch = findChildRegex.exec(code)) !== null) { - this.addNodePathReference(owner, findChildMatch[1]!, lineNumber, findChildMatch.index, scriptClass); - } - - const findChildAliasRegex = /\bfind_child\s*\(\s*([A-Za-z_]\w*)\b/g; - let findChildAliasMatch; - while ((findChildAliasMatch = findChildAliasRegex.exec(code)) !== null) { - const nodePath = this.resolveStringAlias(aliasOwner, findChildAliasMatch[1]!); - if (!nodePath) continue; - this.addNodePathReference(owner, nodePath, lineNumber, findChildAliasMatch.index, scriptClass); - } - - const projectFindNodeRegex = /\b_find_node\s*\(\s*[^,\n]+,\s*["']([^"']+)["']/g; - let projectFindNodeMatch; - while ((projectFindNodeMatch = projectFindNodeRegex.exec(code)) !== null) { - this.addNodePathReference(owner, projectFindNodeMatch[1]!, lineNumber, projectFindNodeMatch.index, scriptClass); - } - - const projectFindNodeAliasRegex = /\b_find_node\s*\(\s*[^,\n]+,\s*([A-Za-z_]\w*)\b/g; - let projectFindNodeAliasMatch; - while ((projectFindNodeAliasMatch = projectFindNodeAliasRegex.exec(code)) !== null) { - const nodePath = this.resolveStringAlias(aliasOwner, projectFindNodeAliasMatch[1]!); - if (!nodePath) continue; - this.addNodePathReference(owner, nodePath, lineNumber, projectFindNodeAliasMatch.index, scriptClass); - } - - const getNodeFormattedRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*["']([^"']*%d[^"']*)["']\s*%/g; - let getNodeFormattedMatch; - while ((getNodeFormattedMatch = getNodeFormattedRegex.exec(code)) !== null) { - const formattedNodePath = this.formattedNodePathBase(getNodeFormattedMatch[1]!); - if (formattedNodePath) { - this.addNodePathReference(owner, formattedNodePath, lineNumber, getNodeFormattedMatch.index, scriptClass); - } - } - - const formattedNodePathVariableRegex = /\b[A-Za-z_]\w*(?:_name|_path)\s*:=?\s*["']([^"']*%d[^"']*)["']\s*%/g; - let formattedNodePathVariableMatch; - while ((formattedNodePathVariableMatch = formattedNodePathVariableRegex.exec(code)) !== null) { - const formattedNodePath = this.formattedNodePathBase(formattedNodePathVariableMatch[1]!); - if (formattedNodePath) { - const variableName = (code.slice(formattedNodePathVariableMatch.index).match(/\b([A-Za-z_]\w*(?:_name|_path))\s*:=?/) || [])[1]; - if (variableName) this.addNodePathAlias(aliasOwner, variableName, formattedNodePath); - this.addNodePathReference(owner, formattedNodePath, lineNumber, formattedNodePathVariableMatch.index, scriptClass); - } - } - - const getNodeConstantRegex = /\b(?:get_node|get_node_or_null|has_node)\s*\(\s*([A-Za-z_]\w*)\s*\)/g; - let getNodeConstantMatch; - while ((getNodeConstantMatch = getNodeConstantRegex.exec(code)) !== null) { - const constName = getNodeConstantMatch[1]!; - const nodePath = this.lookupNodePathAlias(aliasOwner, constName) ?? this.stringConstants.get(constName); - if (!nodePath) continue; - this.addNodePathReference(owner, nodePath, lineNumber, getNodeConstantMatch.index, scriptClass); - } - - const helperCallRegex = /\b([A-Za-z_]\w*)\s*\(([^)]*)\)/g; - let helperCallMatch; - while ((helperCallMatch = helperCallRegex.exec(code)) !== null) { - const helperName = helperCallMatch[1]!; - const argumentIndex = this.nodeLookupHelperArgumentIndex.get(helperName); - if (argumentIndex === undefined) continue; - const args = this.splitCallArguments(helperCallMatch[2]!); - const nodePath = this.resolveStringArgument(aliasOwner, args[argumentIndex]); - if (!nodePath) continue; - this.addNodePathReference(owner, nodePath, lineNumber, helperCallMatch.index, scriptClass); - } - } - - private extractStringNodePathAlias(owner: string, code: string): void { - const stringAliasRegex = /\b(?:var|const)\s+([A-Za-z_]\w*)\s*(?::\s*[A-Za-z_]\w*)?\s*:=?\s*&?["']([^"']+)["']/g; - let stringAliasMatch; - while ((stringAliasMatch = stringAliasRegex.exec(code)) !== null) { - const value = stringAliasMatch[2]!; - if (this.isLikelyNodePath(value)) { - this.addNodePathAlias(owner, stringAliasMatch[1]!, value); - } - } - } - - private resolveStringArgument(owner: string, argument: string | undefined): string | null { - if (!argument) return null; - const literal = argument.match(/^\s*&?["']([^"']+)["']\s*$/); - if (literal) return literal[1]!; - - const identifier = argument.match(/^\s*([A-Za-z_]\w*)\s*$/); - if (!identifier) return null; - return this.resolveStringAlias(owner, identifier[1]!); - } - - private resolveStringAlias(owner: string, name: string): string | null { - return this.lookupNodePathAlias(owner, name) ?? this.stringConstants.get(name) ?? null; - } - - private extractNodeLookupHelpers(functionScopes: FunctionScope[]): void { - if (this.nodeLookupHelperArgumentIndex.size > 0) return; - - for (let i = 0; i < functionScopes.length; i++) { - const scope = functionScopes[i]!; - const node = this.nodes.find((candidate) => candidate.id === scope.id); - if (!node) continue; - - const functionLine = this.stripComment(this.lines[scope.startLine - 1] ?? ''); - const params = this.extractFunctionParameterNames(functionLine); - if (params.length === 0) continue; - - const endLineExclusive = this.functionBodyEndLine(scope, functionScopes, i); - const body = this.lines - .slice(scope.startLine, endLineExclusive - 1) - .map((line) => this.stripComment(line)) - .join('\n'); - - for (let paramIndex = 0; paramIndex < params.length; paramIndex++) { - const paramName = params[paramIndex]!; - const escaped = this.escapeRegExp(paramName); - const directLookupRegex = new RegExp(`\\b(?:get_node|get_node_or_null|has_node|find_child)\\s*\\(\\s*${escaped}\\b`); - const projectLookupRegex = new RegExp(`\\b_find_node\\s*\\([^,\\n]+,\\s*${escaped}\\b`); - if (directLookupRegex.test(body) || projectLookupRegex.test(body)) { - this.nodeLookupHelperArgumentIndex.set(node.name, paramIndex); - break; - } - } - } - } - - private extractFunctionParameterNames(functionLine: string): string[] { - const match = functionLine.match(/\bfunc\s+[A-Za-z_]\w*\s*\(([^)]*)\)/); - if (!match) return []; - return this.splitCallArguments(match[1]!) - .map((arg) => (arg.trim().match(/^([A-Za-z_]\w*)/) || [])[1]) - .filter((name): name is string => Boolean(name)); - } - - private functionBodyEndLine(scope: FunctionScope, functionScopes: FunctionScope[], scopeIndex: number): number { - for (let i = scopeIndex + 1; i < functionScopes.length; i++) { - const next = functionScopes[i]!; - if (next.indent <= scope.indent) return next.startLine; - } - return this.lines.length + 1; - } - - private splitCallArguments(args: string): string[] { - const result: string[] = []; - let start = 0; - let depth = 0; - let inSingle = false; - let inDouble = false; - for (let i = 0; i < args.length; i++) { - const char = args[i]; - const prev = args[i - 1]; - if (char === "'" && !inDouble && prev !== '\\') inSingle = !inSingle; - if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble; - if (inSingle || inDouble) continue; - if (char === '(' || char === '[' || char === '{') depth += 1; - if (char === ')' || char === ']' || char === '}') depth -= 1; - if (char === ',' && depth === 0) { - result.push(args.slice(start, i)); - start = i + 1; - } - } - result.push(args.slice(start)); - return result; - } - - private escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - - private addNodePathReference(owner: string, nodePath: string, lineNumber: number, column: number, scriptClass: Node | null): void { - const cleaned = nodePath.replace(/^[$%]/, ''); - const name = this.nodePathReceiverName(cleaned); - this.addReference(owner, name, 'references', lineNumber, column); - if (cleaned.includes('/')) { - this.addReference(owner, cleaned, 'references', lineNumber, column); - } - if (scriptClass && cleaned) { - this.addReference(owner, `${scriptClass.name}/${cleaned}`, 'references', lineNumber, column); - } - } - - private addDynamicNodeNameDeclaration(name: string, rawLine: string, signature: string, lineNumber: number, owner: string): void { - if (this.dynamicNodeNames.has(name)) return; - this.dynamicNodeNames.add(name); - const node = this.createNode( - 'component', - name, - `${this.filePath}::dynamic_node:${name}:${lineNumber}`, - lineNumber, - rawLine.indexOf(name), - lineNumber, - rawLine.length - ); - node.signature = signature; - this.addContains(owner, node.id); - } - - private addNodePathAlias(owner: string, alias: string, nodePath: string): void { - if (!this.nodePathAliases.has(owner)) this.nodePathAliases.set(owner, new Map()); - this.nodePathAliases.get(owner)!.set(alias, nodePath); - } - - private lookupNodePathAlias(owner: string, alias: string): string | undefined { - return this.nodePathAliases.get(owner)?.get(alias); - } - - private extractFormattedNodePathBase(code: string): string | null { - if (!/\b(?:get_node|get_node_or_null|has_node)\s*\(/.test(code) && !/\b[A-Za-z_]\w*\s*:=?\s*["'][^"']*%d/.test(code)) { - return null; - } - - const formattedStringMatch = code.match(/["']([^"']*%d[^"']*)["']\s*%/); - if (!formattedStringMatch) return null; - return this.formattedNodePathBase(formattedStringMatch[1]!); - } - - private formattedNodePathBase(nodePath: string): string | null { - if (!nodePath.includes('%d')) return null; - const stripped = nodePath.replace(/%d/g, ''); - if (!/^[A-Z_][A-Za-z0-9_]*(?:\/[A-Z_][A-Za-z0-9_]*)*$/.test(stripped)) return null; - return stripped; - } - - private isSimpleNodeName(value: string): boolean { - return /^[A-Z_][A-Za-z0-9_]*$/.test(value); - } - - private isLikelyNodePath(value: string): boolean { - return /^[A-Z_][A-Za-z0-9_]*(?:\/[A-Z_][A-Za-z0-9_]*)*$/.test(value); - } - - private createDeclarationNode(kind: NodeKind, name: string, rawLine: string, line: number, indent: number): Node { - const column = rawLine.indexOf(name); - return this.createNode(kind, name, `${this.filePath}::${name}`, line, column < 0 ? indent : column, line, rawLine.length); - } - - private createNode(kind: NodeKind, name: string, qualifiedName: string, startLine: number, startColumn: number, endLine: number, endColumn: number): Node { - const node: Node = { - id: generateNodeId(this.filePath, kind, name, startLine), - kind, - name, - qualifiedName, - filePath: this.filePath, - language: 'gdscript', - startLine, - endLine, - startColumn, - endColumn, - updatedAt: Date.now(), - }; - this.nodes.push(node); - return node; - } - - private addContains(source: string, target: string): void { - this.edges.push({ source, target, kind: 'contains' }); - } - - private addReference(fromNodeId: string, referenceName: string, referenceKind: UnresolvedReference['referenceKind'], line: number, column: number): void { - this.unresolvedReferences.push({ - fromNodeId, - referenceName, - referenceKind, - line, - column, - filePath: this.filePath, - language: 'gdscript', - }); - } - - private indentOf(line: string): number { - let indent = 0; - for (const char of line) { - if (char === ' ') indent += 1; - else if (char === '\t') indent += 4; - else break; - } - return indent; - } - - private stripComment(line: string): string { - let inSingle = false; - let inDouble = false; - for (let i = 0; i < line.length; i++) { - const char = line[i]; - const prev = line[i - 1]; - if (char === "'" && !inDouble && prev !== '\\') inSingle = !inSingle; - if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble; - if (char === '#' && !inSingle && !inDouble) return line.slice(0, i); - } - return line; - } - - private findCallEnd(code: string, openingParenIndex: number): number { - let depth = 0; - let inSingle = false; - let inDouble = false; - for (let i = openingParenIndex; i < code.length; i++) { - const char = code[i]; - const prev = code[i - 1]; - if (char === "'" && !inDouble && prev !== '\\') inSingle = !inSingle; - if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble; - if (inSingle || inDouble) continue; - if (char === '(') depth += 1; - if (char === ')') { - depth -= 1; - if (depth === 0) return i; - } - } - return code.length; - } - - private getLineNumber(index: number): number { - return this.source.substring(0, index).split('\n').length; - } - - private getLineStart(line: number): number { - let pos = 0; - for (let i = 1; i < line; i++) { - pos += (this.lines[i - 1]?.length ?? 0) + 1; - } - return pos; - } - - private scriptClassNameFromPath(): string { - const base = path.basename(this.filePath, path.extname(this.filePath)); - const words = base.split(/[^A-Za-z0-9]+/).filter(Boolean); - const pascal = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(''); - return pascal || path.basename(this.filePath); - } - - private nodePathReceiverName(nodePath: string): string { - const cleaned = nodePath.replace(/^[$%]/, ''); - const lastSegment = cleaned.split('/').filter(Boolean).pop(); - return lastSegment || cleaned || nodePath; - } -} From 4a4f223437865fc6670d3fd4000cda370c3c5368 Mon Sep 17 00:00:00 2001 From: nazgul Date: Sat, 22 Aug 2026 13:02:36 +0700 Subject: [PATCH 31/33] godot: bridge autoload singletons to their scripts [autoload] entries in project.godot now emit marker components whose signature carries the script's res:// path. The Godot framework resolver maps bare global receivers (GameState.reset()) onto the script's class or same-name method; methods that don't exist there stay unresolved (silent beats wrong). --- __tests__/resolution.test.ts | 61 ++++++++++++++++++++++ src/extraction/godot-resource-extractor.ts | 31 ++++++++++- src/resolution/frameworks/godot.ts | 58 +++++++++++++++++++- 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index b933cf960..f7301a239 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -204,6 +204,67 @@ describe('Resolution Module', () => { 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'), diff --git a/src/extraction/godot-resource-extractor.ts b/src/extraction/godot-resource-extractor.ts index 439821107..12202a6e3 100644 --- a/src/extraction/godot-resource-extractor.ts +++ b/src/extraction/godot-resource-extractor.ts @@ -19,6 +19,7 @@ export class GodotResourceExtractor { private nodesByScenePath = new Map(); private uniqueNameToNode = new Map(); private rootNode: Node | null = null; + private inAutoloadSection = false; constructor(filePath: string, source: string) { this.filePath = filePath; @@ -70,18 +71,24 @@ export class GodotResourceExtractor { 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 (currentOwner) this.extractSectionProperty(currentOwner, line, lineNumber); + 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'); @@ -134,6 +141,28 @@ export class GodotResourceExtractor { 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; diff --git a/src/resolution/frameworks/godot.ts b/src/resolution/frameworks/godot.ts index e211a7b8b..9e15f0116 100644 --- a/src/resolution/frameworks/godot.ts +++ b/src/resolution/frameworks/godot.ts @@ -14,6 +14,9 @@ export const godotResolver: FrameworkResolver = { 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; @@ -77,8 +80,59 @@ function tryResolveUniqueName(ref: UnresolvedRef, context: ResolutionContext): R return null; } -function tryResolveSignal(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { - const name = ref.referenceName; +/** + * `[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( From bd64ae900a39a7e40f23866ba9a5220290141f2f Mon Sep 17 00:00:00 2001 From: nazgul Date: Sat, 22 Aug 2026 13:34:14 +0700 Subject: [PATCH 32/33] godot: bridge engine virtuals, has_method dispatch, scene connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three dynamic-dispatch channels so Godot flows connect end-to-end: - class → _ready/_process/_input/... synthesized calls edges (engine virtuals have no static caller anywhere) - has_method("x") joins call()/Callable() in the string-dispatch family - .tscn [connection] wiring now carries the to-node's script path and a synthesizer links button press → handler method across files (unique candidate only — silent beats wrong) --- __tests__/extraction.test.ts | 13 +++ __tests__/frameworks-integration.test.ts | 96 +++++++++++++++++++ src/db/queries.ts | 10 ++ src/extraction/godot-resource-extractor.ts | 9 ++ src/extraction/languages/gdscript.ts | 7 ++ src/resolution/callback-synthesizer.ts | 102 +++++++++++++++++++++ 6 files changed, 237 insertions(+) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index bfea17c63..17fc12480 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -432,6 +432,19 @@ func real_call_target() -> void: // 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', () => { diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 344a0f6c9..a0dd49168 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -908,3 +908,99 @@ describe('Go gRPC stub→impl synthesis', () => { } }); }); + +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(); + } + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index adf239268..1713361cc 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1353,6 +1353,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 index 12202a6e3..ffacec3ff 100644 --- a/src/extraction/godot-resource-extractor.ts +++ b/src/extraction/godot-resource-extractor.ts @@ -20,6 +20,8 @@ export class GodotResourceExtractor { 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; @@ -194,6 +196,9 @@ export class GodotResourceExtractor { 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; } @@ -246,6 +251,10 @@ export class GodotResourceExtractor { 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), }, }); } diff --git a/src/extraction/languages/gdscript.ts b/src/extraction/languages/gdscript.ts index f0bf2e4dd..b44324a3a 100644 --- a/src/extraction/languages/gdscript.ts +++ b/src/extraction/languages/gdscript.ts @@ -828,6 +828,13 @@ function runReferencePasses(ctx: ExtractorContext, state: ExtractorState): void 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); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 773d7e99e..89bd70206 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1534,6 +1534,101 @@ 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', +]); + +function godotEngineVirtualEdges(queries: QueryBuilder): Edge[] { + 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). + */ +function godotSceneConnectionEdges(queries: QueryBuilder): Edge[] { + 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; +} + function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] { // 1. Find the chain dispatcher(s): a Go method that invokes a `handlers` slice by index. const dispatchers: Node[] = []; @@ -1714,6 +1809,11 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo const rnXPlatEdges = rnCrossPlatformEdges(queries); const mybatisEdges = mybatisJavaXmlEdges(queries); const ginEdges = ginMiddlewareChainEdges(queries, ctx); + const godotVirtualEdges = godotEngineVirtualEdges(queries); + // Scene-connection bridging reads heuristic wiring persisted by extraction, + // so it must run BEFORE the merged batch insert — its own edges are added to + // the same batch below. + const godotSceneEdges = godotSceneConnectionEdges(queries); const merged: Edge[] = []; const seen = new Set(); @@ -1737,6 +1837,8 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo ...rnXPlatEdges, ...mybatisEdges, ...ginEdges, + ...godotVirtualEdges, + ...godotSceneEdges, ]) { const key = `${e.source}>${e.target}`; if (seen.has(key)) continue; From a53dfe3e65cc12100b808766c5b7abac13e0ec3e Mon Sep 17 00:00:00 2001 From: nazgul Date: Sat, 22 Aug 2026 13:39:50 +0700 Subject: [PATCH 33/33] docs: GDScript tree-sitter support + Godot dynamic-dispatch coverage --- CHANGELOG.md | 4 ++++ README.md | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9806578b3..16df5a633 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.0.1] - 2026-06-13 diff --git a/README.md b/README.md index 354af2463..45f9016c4 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ CodeGraph cuts **tokens, tool calls, and wall-clock time on every repo** — acr | **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, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, 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 | @@ -679,6 +679,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 | ## Measured cross-file coverage