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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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 fec22476a86c52c7afb11d7929b8647db47d49c0 Mon Sep 17 00:00:00 2001 From: Ian Snyder <34639515+i-snyder@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:08:45 -0500 Subject: [PATCH 21/24] fix(resolution): resolve GDScript node-path references to Godot scenes (#364) Rebasing #364 onto current main pulled in applyLanguageGate, a cross-language-family gate main added to `references` lookups after #364 branched. GDScript node-path refs (language: gdscript) and scene nodes (language: godot_resource) were both absent from LANGUAGE_FAMILY, so sameLanguageFamily() returned false and every cross-file `.gd` -> `.tscn` node-path reference was silently dropped by the merge, even though it built and ran without error. Register gdscript/godot_resource as a shared 'godot' family, the same pattern as the existing csharp/razor -> 'dotnet' entry. --- src/resolution/name-matcher.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e4e5dde94..28d34eeed 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -151,6 +151,10 @@ const LANGUAGE_FAMILY: Record = { // Razor/Blazor markup names C# types — same family so `@model Foo` / // `` resolve to their `.cs` class through the cross-family gate. csharp: 'dotnet', razor: 'dotnet', + // A GDScript node-path reference (`$MarginContainer/StatusIconTemplate`) + // names a node declared in a `.tscn` scene — same family so it resolves + // through the cross-family gate instead of being dropped as unrelated. + gdscript: 'godot', godot_resource: 'godot', }; export function sameLanguageFamily(a: string, b: string): boolean { if (a === b) return true; From 8476ada0f0fde7173287e7bc96dc49062a07b9a7 Mon Sep 17 00:00:00 2001 From: Ian Snyder <34639515+i-snyder@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:08:51 -0500 Subject: [PATCH 22/24] chore: bump EXTRACTION_VERSION for GDScript/Godot support Adding a new language/framework extractor is a documented bump trigger for EXTRACTION_VERSION (see this file's own doc comment), so existing indexes are prompted to re-index and pick up GDScript/Godot support instead of silently staying stale. --- src/extraction/extraction-version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 618a1b1c3..07ccbb964 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 24; +export const EXTRACTION_VERSION = 25; From 1337983ee7a7382231027cd3ef16997fb16edf2b Mon Sep 17 00:00:00 2001 From: Ian Snyder <34639515+i-snyder@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:08:57 -0500 Subject: [PATCH 23/24] docs(changelog): add GDScript/Godot support entry (#364) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index abc341c09..208356d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- CodeGraph now indexes GDScript and Godot scene/resource files (`.gd`, `.tscn`, `.tres`), so Godot 4 projects get the same code intelligence as every other supported language. GDScript classes, methods, signals, constants, and variables are extracted with full type signatures, and Godot scenes are parsed into their own node graph — scene node containment, `ExtResource`/instanced-scene references, and a script's `res://` path all resolve to the real files and symbols they point to. Cross-file GDScript call graphs, `codegraph query`, `codegraph callers`, and the MCP tools all work against Godot projects as a result — previously CodeGraph produced no index at all for `.gd`/`.tscn`/`.tres` files. Thanks @KirisamaMarisa for the original GDScript/Godot support. (#364) ## [1.1.6] - 2026-06-30 From 957c0c23ebb8d5cb417f2e57f2881d23e5b2b502 Mon Sep 17 00:00:00 2001 From: Ian Snyder <34639515+i-snyder@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:15:10 -0500 Subject: [PATCH 24/24] fix(cli): match MCP's language check for Godot scene-instance components The CLI's isGodotSceneInstanceComponent (src/bin/codegraph.ts) omitted the node.language === 'godot_resource' check its MCP counterpart (src/mcp/tools.ts) has, so the CLI's callers/impact matching was slightly looser than the MCP tools' for this case. CliSearchNode already lacked a `language` field even though the underlying Node type (and cg.searchNodes() results) always carries one - added it and brought the check in line with the MCP copy. --- src/bin/codegraph.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 3d06e108f..037cf90d2 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -75,6 +75,7 @@ type CliSearchNode = { qualifiedName: string; startLine?: number; signature?: string; + language?: string; }; function nodeMatchesSymbol(node: CliSearchNode, symbol: string): boolean { @@ -101,7 +102,7 @@ function findCliSymbolMatches( 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'); + return node.kind === 'component' && node.language === 'godot_resource' && node.filePath.endsWith('.tscn') && signature.includes('instance=ExtResource'); } const importESM = new Function('specifier', 'return import(specifier)') as