fix(lua): index assignment-style function definitions (#1650) (#1778)

Apply upstream PR #1623 by danusha2345 (fix commit aa777063),
which also addresses #1616, to the current main base. Preserve the
upstream WASM and Rust implementations and regression coverage.

Index assigned locals, table members, static string keys and nested
callback tables as callable symbols, with calls owned by those symbols.
Keep dynamic keys unguessed. Add #1650 to the Unreleased changelog and
retain the existing re-index guidance without an extraction-version bump.

Verified on Linux x64 with Node 22.19.0:
- native kernel build, tsc, asset copy, executable CLI
- issue repro: 3 nodes / 2 edges -> 4 nodes / 4 edges
- EPR.PowerController::SyncHydroPower is indexed; its caller is client.lua
- syncHydroPower depth-2 impact reaches client.lua
- extraction/resolution/Lua parity: 844 tests passed (kernel expected)
- forced-WASM Lua/Luau extraction/resolution: 20 tests passed

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 10:50:09 -05:00
committed by GitHub
co-authored by Colby McHenry
parent 195888d71f
commit 8c047342cd
8 changed files with 332 additions and 19 deletions
+1
View File
@@ -213,6 +213,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
#### Symbols, tests and the viewer #### Symbols, tests and the viewer
- 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, #1650)
- **Functions bound with `const` inside another function are symbols now.** `const handleClear = () => {…}` inside a React component — every handler that skips `useCallback` — was invisible to `callers`, `callees` and impact, answering "Symbol not found" exactly the way a function with no callers would. It is indexed like its module-level twin, contained by the enclosing function, with its own calls. Re-index after upgrading. (#1669) - **Functions bound with `const` inside another function are symbols now.** `const handleClear = () => {…}` inside a React component — every handler that skips `useCallback` — was invisible to `callers`, `callees` and impact, answering "Symbol not found" exactly the way a function with no callers would. It is indexed like its module-level twin, contained by the enclosing function, with its own calls. Re-index after upgrading. (#1669)
- `codegraph callers`, `callees`, and `query` now clearly report when their result limit hides additional matches, including exact totals in callers/callees JSON output; the `codegraph_callers` and `codegraph_callees` MCP answers carry the same "showing N of M" note. (#1639, #1674) - `codegraph callers`, `callees`, and `query` now clearly report when their result limit hides additional matches, including exact totals in callers/callees JSON output; the `codegraph_callers` and `codegraph_callees` MCP answers carry the same "showing N of M" note. (#1639, #1674)
- CommonJS controllers written as `exports.getItems = async (req, res) => {…}` or `module.exports.x = function () {…}` are now indexed as exported functions, so `node`, `callers` and impact find every Express handler in that style and the calls inside them belong to the handler instead of the file. Re-index JavaScript projects after upgrading. (#1675) - CommonJS controllers written as `exports.getItems = async (req, res) => {…}` or `module.exports.x = function () {…}` are now indexed as exported functions, so `node`, `callers` and impact find every Express handler in that style and the calls inside them belong to the handler instead of the file. Re-index JavaScript projects after upgrading. (#1675)
+52
View File
@@ -8786,6 +8786,58 @@ function M:send(data) return self end
const send = methods.find((m) => m.name === 'send'); const send = methods.find((m) => m.name === 'send');
expect(send?.qualifiedName).toBe('M::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', () => { describe('Variable extraction', () => {
+10 -1
View File
@@ -24,7 +24,7 @@ local function localFn(...)
return select("#", ...) return select("#", ...)
end end
-- doc for anonAssigned (variable, initializer invisible) -- doc for anonAssigned (function named from its local binding)
local anonAssigned = function(v) local anonAssigned = function(v)
return hidden(v) return hidden(v)
end end
@@ -68,6 +68,15 @@ M.assigned = function(z)
return topFn(z) return topFn(z)
end 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 } M.handlers = { on_start = topFn, on_stop = localFn, skipped = missing }
local tbl = { cb = topFn, [1] = localFn, nested = { deep_cb = topFn } } local tbl = { cb = topFn, [1] = localFn, nested = { deep_cb = topFn } }
+16
View File
@@ -127,6 +127,22 @@ describe.skipIf(!kernelBuilt)('kernel Lua/Luau extraction parity', () => {
// lua functions carry NO isExported (undefined — not false). // lua functions carry NO isExported (undefined — not false).
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'topFn'); const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'topFn');
expect(fn?.isExported).toBeUndefined(); 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. // variables DO carry isExported === false.
const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'core'); const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'core');
expect(v?.isExported).toBe(false); expect(v?.isExported).toBe(false);
+26
View File
@@ -2055,6 +2055,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)', () => { describe('Watchdog-safe resolution on collision-heavy repos (#1122)', () => {
// On a large Java-style repo, per-ref resolution cost is unbounded in the // 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 // worst case (a colliding method name whose candidate set misses the LRU
+132 -12
View File
@@ -468,7 +468,7 @@ impl<'t> Walker<'t> {
} }
// plain path returns false → children re-visited (the // plain path returns false → children re-visited (the
// typeof(require(...)) alias+import pair rides this). // typeof(require(...)) alias+import pair rides this).
} else if kind == "variable_declaration" { } else if matches!(kind, "variable_declaration" | "assignment_statement") {
self.extract_variable(node); self.extract_variable(node);
// Initializer subtrees are never walked — candidates only. // Initializer subtrees are never walked — candidates only.
self.scan_fn_ref_subtree(node, 0); self.scan_fn_ref_subtree(node, 0);
@@ -578,21 +578,38 @@ impl<'t> Walker<'t> {
} }
None => Vec::new(), None => Vec::new(),
}; };
let names: Vec<Node<'t>> = match var_list { let targets: Vec<Node<'t>> = match var_list {
Some(vl) => { Some(vl) => {
let mut c = vl.walk(); 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(), None => Vec::new(),
}; };
for (i, name_node) in names.iter().enumerate() { for (i, name_node) in targets.iter().enumerate() {
let name = self.text(*name_node); let Some((name, receiver, full_name)) = self.lua_assignment_target(*name_node) else {
if name.is_empty() { 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; continue;
} }
// Positional value pairing; a missing value → NO signature key. // Positional value pairing; a missing value → NO signature key.
let signature = values.get(i).map(|v| util::init_signature(self.text(*v))); let signature = value.map(|v| util::init_signature(self.text(v)));
let name = name.to_string();
self.create_node( self.create_node(
"variable", "variable",
&name, &name,
@@ -607,6 +624,110 @@ impl<'t> Walker<'t> {
} }
} }
fn lua_assignment_target(&self, node: Node<'t>) -> Option<(String, Option<String>, 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<String>,
docstring: Option<String>,
) {
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<Node<'t>> = 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 -------- // --- extractTypeAlias (2890; plain path 2967-2991) — luau only --------
/// Returns skipChildren (always false on the plain path). /// Returns skipChildren (always false on the plain path).
@@ -790,13 +911,12 @@ impl<'t> Walker<'t> {
return; return;
} }
// Halt at nested function definitions (their bodies are walked — and // Halt at nested function definitions (their bodies are walked — and
// attributed — by extractFunction). function_definition (anonymous) // attributed — by extractFunction). Lua function_definition values are
// is deliberately NOT in the halt list — the scan descends into // now extracted from their assignment target and must stop this scan too.
// anonymous initializer bodies, attributing candidates to the file.
if depth > 0 if depth > 0
&& matches!( && matches!(
node.kind(), node.kind(),
"function_declaration" | "arrow_function" | "function_expression" "function_declaration" | "function_definition" | "arrow_function" | "function_expression"
| "lambda_literal" | "lambda_expression" | "lambda_literal" | "lambda_expression"
) )
{ {
+4 -1
View File
@@ -75,7 +75,10 @@ export const luaExtractor: LanguageExtractor = {
typeAliasTypes: [], typeAliasTypes: [],
importTypes: [], // `require` is a function_call — handled in visitNode below importTypes: [], // `require` is a function_call — handled in visitNode below
callTypes: ['function_call'], 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', nameField: 'name',
bodyField: 'body', bodyField: 'body',
paramsField: 'parameters', paramsField: 'parameters',
+91 -5
View File
@@ -648,6 +648,7 @@ export class TreeSitterExtractor {
const nodeType = node.type; const nodeType = node.type;
if (depth > 0 && ( if (depth > 0 && (
this.extractor?.functionTypes.includes(nodeType) || this.extractor?.functionTypes.includes(nodeType) ||
((this.language === 'lua' || this.language === 'luau') && nodeType === 'function_definition') ||
nodeType === 'arrow_function' || nodeType === 'arrow_function' ||
nodeType === 'function_expression' || nodeType === 'function_expression' ||
nodeType === 'lambda_literal' || nodeType === 'lambda_literal' ||
@@ -2905,14 +2906,28 @@ export class TreeSitterExtractor {
const varList = assign.namedChildren.find((c) => c.type === 'variable_list'); const varList = assign.namedChildren.find((c) => c.type === 'variable_list');
const exprList = assign.namedChildren.find((c) => c.type === 'expression_list'); const exprList = assign.namedChildren.find((c) => c.type === 'expression_list');
const values = exprList ? exprList.namedChildren : []; const values = exprList ? exprList.namedChildren : [];
const names = varList ? varList.namedChildren.filter((c) => c.type === 'identifier') : []; const targets = varList ? varList.namedChildren : [];
names.forEach((nameNode, i) => { targets.forEach((nameNode, i) => {
const name = getNodeText(nameNode, this.source);
if (!name) return;
const valueNode = values[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 initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
const initSignature = initValue ? `= ${initValue}${initValue.length >= 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') { } else if (this.language === 'c') {
// C: a `declaration` node's name nests inside the `declarator` field — // C: a `declaration` node's name nests inside the `declarator` field —
@@ -2992,6 +3007,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<Node> = { 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). * Extract a type alias (e.g. `export type X = ...` in TypeScript).
* For languages like Go, resolveTypeAliasKind detects when the type_spec * For languages like Go, resolveTypeAliasKind detects when the type_spec