diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..a36b4b28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- Lua and Luau function expressions assigned to locals, table members, or keyed table fields are now indexed as callable nodes. Calls from `local f = function() ... end`, `M.f = function() ... end`, and callback tables such as `M.handlers = { onClick = function() ... end }` are attributed to the named function or method instead of collapsing onto the file node, so callers and impact no longer omit these handlers. Re-index after upgrading. (#1616) ## [1.6.0] - 2026-08-26 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index ad0ba2374..c104549f8 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -8486,6 +8486,58 @@ function M:send(data) return self end const send = methods.find((m) => m.name === 'send'); expect(send?.qualifiedName).toBe('M::send'); }); + + it('should name function expressions from local, member, and table-field bindings', () => { + const code = ` +local function helper() return 1 end +local localFn = function() return helper() end +local M = { + callbacks = { + onStart = function() return helper() end, + ["onStop"] = function() return helper() end, + [DYNAMIC] = function() return helper() end, + }, +} +M.assignedFn = function() return helper() end +M["bracketFn"] = function() return helper() end +localFn() +`; + const result = extractFromSource('handlers.lua', code); + const localFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'localFn'); + const assignedFn = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName === 'M::assignedFn' + ); + const onStart = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStart' + ); + const onStop = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStop' + ); + const bracketFn = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName === 'M::bracketFn' + ); + + expect(localFn).toBeDefined(); + expect(assignedFn).toBeDefined(); + expect(onStart).toBeDefined(); + expect(onStop).toBeDefined(); + expect(bracketFn).toBeDefined(); + expect(result.nodes.some((n) => n.name === 'DYNAMIC')).toBe(false); + expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'localFn')).toBe(false); + + for (const callable of [localFn, assignedFn, onStart, onStop, bracketFn]) { + expect( + result.unresolvedReferences.some( + (r) => r.fromNodeId === callable!.id && r.referenceKind === 'calls' && r.referenceName === 'helper' + ) + ).toBe(true); + } + expect( + result.unresolvedReferences.some( + (r) => r.referenceKind === 'calls' && r.referenceName === 'localFn' + ) + ).toBe(true); + }); }); describe('Variable extraction', () => { diff --git a/__tests__/fixtures/kernel-parity/torture.lua b/__tests__/fixtures/kernel-parity/torture.lua index 52da48ca0..1b3c21cfb 100644 --- a/__tests__/fixtures/kernel-parity/torture.lua +++ b/__tests__/fixtures/kernel-parity/torture.lua @@ -24,7 +24,7 @@ local function localFn(...) return select("#", ...) end --- doc for anonAssigned (variable, initializer invisible) +-- doc for anonAssigned (function named from its local binding) local anonAssigned = function(v) return hidden(v) end @@ -68,6 +68,15 @@ M.assigned = function(z) return topFn(z) end +M.callbacks = { + on_start = function() + return topFn(17) + end, + ["on_stop"] = function() + return topFn(18) + end, +} + M.handlers = { on_start = topFn, on_stop = localFn, skipped = missing } local tbl = { cb = topFn, [1] = localFn, nested = { deep_cb = topFn } } diff --git a/__tests__/kernel-lua-parity.test.ts b/__tests__/kernel-lua-parity.test.ts index 2e1e9b27d..e87b007e3 100644 --- a/__tests__/kernel-lua-parity.test.ts +++ b/__tests__/kernel-lua-parity.test.ts @@ -127,6 +127,22 @@ describe.skipIf(!kernelBuilt)('kernel Lua/Luau extraction parity', () => { // lua functions carry NO isExported (undefined — not false). const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'topFn'); expect(fn?.isExported).toBeUndefined(); + expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'anonAssigned')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M::assigned')).toBe(true); + expect( + result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::on_start') + ).toBe(true); + expect( + result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::on_stop') + ).toBe(true); + for (const qualifiedName of ['M::assigned', 'M.callbacks::on_start', 'M.callbacks::on_stop']) { + const callable = result.nodes.find((n) => n.qualifiedName === qualifiedName)!; + expect( + refs.some( + (r) => r.fromNodeId === callable.id && r.referenceKind === 'calls' && r.referenceName === 'topFn' + ) + ).toBe(true); + } // variables DO carry isExported === false. const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'core'); expect(v?.isExported).toBe(false); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index decaadee5..63712ad34 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -2001,6 +2001,32 @@ func main() { }); }); + describe('Lua function-expression resolution (#1616)', () => { + it('attributes helper calls to each assigned callable instead of the file node', async () => { + fs.writeFileSync( + path.join(tempDir, 'util.lua'), + `util = {}\nfunction util.helper() return 1 end\nreturn util\n` + ); + fs.writeFileSync( + path.join(tempDir, 'handlers.lua'), + `local M = {}\nfunction M.namedFn() return util.helper() end\nM.assignedFn = function() return util.helper() end\nM.callbacks = { onStart = function() return util.helper() end }\nreturn M\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const helper = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'util::helper'); + expect(helper).toBeDefined(); + const callers = cg.getCallers(helper!.id).map((c) => c.node); + expect(callers.some((n) => n.qualifiedName === 'M::namedFn')).toBe(true); + expect(callers.some((n) => n.qualifiedName === 'M::assignedFn')).toBe(true); + expect(callers.some((n) => n.qualifiedName === 'M.callbacks::onStart')).toBe(true); + expect(callers.some((n) => n.kind === 'file' && n.filePath === 'handlers.lua')).toBe(false); + }); + }); + describe('Watchdog-safe resolution on collision-heavy repos (#1122)', () => { // On a large Java-style repo, per-ref resolution cost is unbounded in the // worst case (a colliding method name whose candidate set misses the LRU diff --git a/codegraph-kernel/src/lua.rs b/codegraph-kernel/src/lua.rs index b3cd32a76..af1245034 100644 --- a/codegraph-kernel/src/lua.rs +++ b/codegraph-kernel/src/lua.rs @@ -468,7 +468,7 @@ impl<'t> Walker<'t> { } // plain path returns false → children re-visited (the // typeof(require(...)) alias+import pair rides this). - } else if kind == "variable_declaration" { + } else if matches!(kind, "variable_declaration" | "assignment_statement") { self.extract_variable(node); // Initializer subtrees are never walked — candidates only. self.scan_fn_ref_subtree(node, 0); @@ -578,21 +578,38 @@ impl<'t> Walker<'t> { } None => Vec::new(), }; - let names: Vec> = match var_list { + let targets: Vec> = match var_list { Some(vl) => { let mut c = vl.walk(); - vl.named_children(&mut c).filter(|n| n.kind() == "identifier").collect() + vl.named_children(&mut c).collect() } None => Vec::new(), }; - for (i, name_node) in names.iter().enumerate() { - let name = self.text(*name_node); - if name.is_empty() { + for (i, name_node) in targets.iter().enumerate() { + let Some((name, receiver, full_name)) = self.lua_assignment_target(*name_node) else { + continue; + }; + let value = values.get(i).copied(); + if let Some(value) = value { + if value.kind() == "function_definition" { + self.extract_lua_function_value( + value, + name, + receiver, + docstring.clone(), + ); + continue; + } + if value.kind() == "table_constructor" { + self.extract_lua_table_functions(value, full_name); + } + } + // Dotted assignments update table members, not standalone vars. + if receiver.is_some() || node.kind() == "assignment_statement" { continue; } // Positional value pairing; a missing value → NO signature key. - let signature = values.get(i).map(|v| util::init_signature(self.text(*v))); - let name = name.to_string(); + let signature = value.map(|v| util::init_signature(self.text(v))); self.create_node( "variable", &name, @@ -607,6 +624,110 @@ impl<'t> Walker<'t> { } } + fn lua_assignment_target(&self, node: Node<'t>) -> Option<(String, Option, String)> { + if node.kind() == "identifier" { + let name = self.text(node).trim().to_string(); + if name.is_empty() { + return None; + } + return Some((name.clone(), None, name)); + } + if !matches!( + node.kind(), + "dot_index_expression" | "method_index_expression" | "bracket_index_expression" + ) { + return None; + } + let table = node.child_by_field_name("table")?; + let field = node + .child_by_field_name("field") + .or_else(|| node.child_by_field_name("method"))?; + let receiver = self.text(table).trim().to_string(); + let name = self.lua_static_field_name(field, node.kind() == "bracket_index_expression"); + if receiver.is_empty() || name.is_empty() { + return None; + } + let full_name = format!("{receiver}.{name}"); + Some((name, Some(receiver), full_name)) + } + + fn lua_static_field_name(&self, node: Node<'t>, bracketed: bool) -> String { + if node.kind() == "identifier" { + return if bracketed { + String::new() + } else { + self.text(node).trim().to_string() + }; + } + if node.kind() == "string" { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind() == "string_content" { + return self.text(child).trim().to_string(); + } + } + } + String::new() + } + + fn extract_lua_function_value( + &mut self, + node: Node<'t>, + name: String, + receiver: Option, + docstring: Option, + ) { + let signature = self.signature_of(node); + let (kind, qualified_name_override, is_exported) = match receiver { + Some(receiver) => ( + "method", + Some(format!("{receiver}::{name}")), + None, + ), + None => ("function", None, self.is_exported_of(node)), + }; + let row = self.create_node( + kind, + &name, + node, + Extra { + docstring, + signature, + qualified_name_override, + is_exported, + ..Default::default() + }, + ); + let Some(row) = row else { return }; + self.stack.push(Scope { row, kind, name }); + if let Some(body) = node.child_by_field_name("body") { + self.visit_body(body); + } + self.stack.pop(); + } + + fn extract_lua_table_functions(&mut self, table: Node<'t>, receiver: String) { + let mut cursor = table.walk(); + let fields: Vec> = table.named_children(&mut cursor).collect(); + for field in fields { + if field.kind() != "field" { + continue; + } + let Some(name_node) = field.child_by_field_name("name") else { continue }; + let Some(value) = field.child_by_field_name("value") else { continue }; + let bracketed = self.text(field).trim_start().starts_with('['); + let name = self.lua_static_field_name(name_node, bracketed); + if name.is_empty() { + continue; + } + if value.kind() == "function_definition" { + self.extract_lua_function_value(value, name, Some(receiver.clone()), None); + } else if value.kind() == "table_constructor" { + self.extract_lua_table_functions(value, format!("{receiver}.{name}")); + } + } + } + // --- extractTypeAlias (2890; plain path 2967-2991) — luau only -------- /// Returns skipChildren (always false on the plain path). @@ -790,13 +911,12 @@ impl<'t> Walker<'t> { return; } // Halt at nested function definitions (their bodies are walked — and - // attributed — by extractFunction). function_definition (anonymous) - // is deliberately NOT in the halt list — the scan descends into - // anonymous initializer bodies, attributing candidates to the file. + // attributed — by extractFunction). Lua function_definition values are + // now extracted from their assignment target and must stop this scan too. if depth > 0 && matches!( node.kind(), - "function_declaration" | "arrow_function" | "function_expression" + "function_declaration" | "function_definition" | "arrow_function" | "function_expression" | "lambda_literal" | "lambda_expression" ) { diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 07ccbb964..8ddab13b8 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 = 25; +export const EXTRACTION_VERSION = 26; diff --git a/src/extraction/languages/lua.ts b/src/extraction/languages/lua.ts index 31094dc1a..ec59a50de 100644 --- a/src/extraction/languages/lua.ts +++ b/src/extraction/languages/lua.ts @@ -75,7 +75,10 @@ export const luaExtractor: LanguageExtractor = { typeAliasTypes: [], importTypes: [], // `require` is a function_call — handled in visitNode below callTypes: ['function_call'], - variableTypes: ['variable_declaration'], // see the `lua` branch in extractVariable + // Top-level assignments can introduce module members just as declarations do: + // `M.run = function() ... end`. The Lua branch in extractVariable ignores + // non-callable member assignments, but extracts function-valued targets. + variableTypes: ['variable_declaration', 'assignment_statement'], nameField: 'name', bodyField: 'body', paramsField: 'parameters', diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index c34dc4716..52a5bebda 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -606,6 +606,7 @@ export class TreeSitterExtractor { const nodeType = node.type; if (depth > 0 && ( this.extractor?.functionTypes.includes(nodeType) || + ((this.language === 'lua' || this.language === 'luau') && nodeType === 'function_definition') || nodeType === 'arrow_function' || nodeType === 'function_expression' || nodeType === 'lambda_literal' || @@ -2817,14 +2818,28 @@ export class TreeSitterExtractor { const varList = assign.namedChildren.find((c) => c.type === 'variable_list'); const exprList = assign.namedChildren.find((c) => c.type === 'expression_list'); const values = exprList ? exprList.namedChildren : []; - const names = varList ? varList.namedChildren.filter((c) => c.type === 'identifier') : []; - names.forEach((nameNode, i) => { - const name = getNodeText(nameNode, this.source); - if (!name) return; + const targets = varList ? varList.namedChildren : []; + targets.forEach((nameNode, i) => { const valueNode = values[i]; + const target = this.luaAssignmentTarget(nameNode); + if (!target) return; + + if (valueNode?.type === 'function_definition') { + this.extractLuaFunctionValue(valueNode, target.name, target.receiver, docstring); + return; + } + + if (valueNode?.type === 'table_constructor') { + this.extractLuaTableFunctions(valueNode, target.fullName); + } + + // A dotted assignment updates a table member; it is not a standalone + // variable node. Function-valued members were handled above. + if (target.receiver || node.type === 'assignment_statement') return; + const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined; const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; - this.createNode(kind, name, nameNode, { docstring, signature: initSignature, isExported }); + this.createNode(kind, target.name, nameNode, { docstring, signature: initSignature, isExported }); }); } else if (this.language === 'c') { // C: a `declaration` node's name nests inside the `declarator` field — @@ -2904,6 +2919,77 @@ export class TreeSitterExtractor { } } + /** Resolve a Lua assignment target into its callable name and optional table receiver. */ + private luaAssignmentTarget(node: SyntaxNode): { name: string; receiver?: string; fullName: string } | null { + if (node.type === 'identifier') { + const name = getNodeText(node, this.source).trim(); + return name ? { name, fullName: name } : null; + } + if ( + node.type !== 'dot_index_expression' && + node.type !== 'method_index_expression' && + node.type !== 'bracket_index_expression' + ) return null; + const table = getChildByField(node, 'table'); + const field = getChildByField(node, 'field') ?? getChildByField(node, 'method'); + if (!table || !field) return null; + const receiver = getNodeText(table, this.source).trim(); + const name = this.luaStaticFieldName(field, node.type === 'bracket_index_expression'); + if (!receiver || !name) return null; + return { name, receiver, fullName: `${receiver}.${name}` }; + } + + /** A statically-known Lua field name; dynamic bracket keys are not callable identities. */ + private luaStaticFieldName(node: SyntaxNode, bracketed: boolean): string { + if (node.type === 'identifier') { + return bracketed ? '' : getNodeText(node, this.source).trim(); + } + if (node.type === 'string') { + const content = node.namedChildren.find((child) => child.type === 'string_content'); + return content ? getNodeText(content, this.source).trim() : ''; + } + return ''; + } + + /** Extract an anonymous Lua function using the name supplied by its assignment target. */ + private extractLuaFunctionValue( + node: SyntaxNode, + name: string, + receiver?: string, + docstring?: string + ): void { + if (!this.extractor) return; + const signature = this.extractor.getSignature?.(node, this.source); + const extra: Partial = { docstring, signature }; + if (receiver) extra.qualifiedName = this.composeReceiverQualifiedName(receiver, name); + else extra.isExported = this.extractor.isExported?.(node, this.source); + + const functionNode = this.createNode(receiver ? 'method' : 'function', name, node, extra); + if (!functionNode) return; + this.nodeStack.push(functionNode.id); + const body = getChildByField(node, this.extractor.bodyField); + if (body) this.visitFunctionBody(body, functionNode.id); + this.nodeStack.pop(); + } + + /** Extract function-valued keyed fields from a Lua table, including nested tables. */ + private extractLuaTableFunctions(table: SyntaxNode, receiver: string): void { + for (const field of table.namedChildren) { + if (field.type !== 'field') continue; + const nameNode = getChildByField(field, 'name'); + const valueNode = getChildByField(field, 'value'); + if (!nameNode || !valueNode) continue; + const bracketed = getNodeText(field, this.source).trimStart().startsWith('['); + const name = this.luaStaticFieldName(nameNode, bracketed); + if (!name) continue; + if (valueNode.type === 'function_definition') { + this.extractLuaFunctionValue(valueNode, name, receiver); + } else if (valueNode.type === 'table_constructor') { + this.extractLuaTableFunctions(valueNode, `${receiver}.${name}`); + } + } + } + /** * Extract a type alias (e.g. `export type X = ...` in TypeScript). * For languages like Go, resolveTypeAliasKind detects when the type_spec