Repository navigation
Lua: assignment-style function definitions (X.Y = function()) are not indexed — and the resulting caller list looks complete #1650
Description
First off: thank you for CodeGraph. We use it daily on a large Project Zomboid
Lua mod, and it has changed how we work.
Being able to ask "who calls this, and
what breaks if I change it" and get an answer in milliseconds instead of a
twenty-minute grep session is worth a lot. This report is meant in that spirit.
Summary
In Lua, X.Y = function() ... end is equivalent to function X.Y() ... end.
CodeGraph indexes the second form but not the first.
The missing symbol is the smaller half. The larger half is that the call graph
silently drops cross-file edges while still returning a plausible,
complete-looking answer.
Repro
power.lua
local function syncHydroPower()
-- ... called from 5 places inside this file
end
EPR = EPR or {}
EPR.PowerController = {}
EPR.PowerController.SyncHydroPower = function() return syncHydroPower() endclient.lua
EPR.PowerController.SyncHydroPower()Expected: EPR.PowerController.SyncHydroPower is a symbol, and the callers
reachable from syncHydroPower include client.lua.
Actual: it is not a symbol, and codegraph node syncHydroPower reports
"5 callers, all in power.lua" — zero cross-file callers. The real external
caller in client.lua is absent from the edge list entirely.
There is no error and no empty result. An empty result makes a user suspicious;
a complete-looking caller list does not. In our case a function was refactored on
the belief that it had no callers outside its own file.
Source-level evidence (main)
codegraph-kernel/src/lua.rs:
- The dispatch ladder branches on
function_declaration,type_definition
(Luau),variable_declarationandfunction_call. A statement-level
assignment_statement— which is whatX.Y = function() endparses to
withoutlocal— has no branch, so no node is minted. extract_variablefilters the name list to identifiers only:
vl.named_children(&mut c).filter(|n| n.kind() == "identifier"). A dotted
target is adot_index_expressionand is dropped. That branch also mints kind
"variable", not"function".maybe_capture_fn_refsdoes handleassignment_statement, but only inrhs
mode, which captures references to existing functions rather than
definitions.
The case is already in your fixtures — but nothing asserts it
__tests__/fixtures/kernel-parity/torture.lua has M.assigned = function(z)
(line 67) and local anonAssigned = function(v) (line 28).
__tests__/kernel-lua-parity.test.ts makes no assertion about either. It
compares the Rust kernel against the WASM extractor for equality
(expect(k.nodes).toEqual(w.nodes)), so both being blind in the same way passes
as parity. A single assertion — "M.assigned appears as a function node" —
would have caught it.
Why this hits Lua harder than other languages
The assignment form is idiomatic wherever a table is populated after creation:
export tables, handler tables, and above all monkey-patching, which is the
normal way third-party code overrides engine functions in this ecosystem.
In one real module of ours the distribution is:
| File | assignment-form definitions |
|---|---|
EPR_VanillaOverrides.lua |
24 |
| 4 other files | 5 combined |
The file with by far the most invisible definitions is the one whose entire
purpose is overriding upstream functions — so the blind spot is concentrated
exactly where "does this override exist, and who reaches it?" is the question
being asked.
Related forms worth checking in the same pass
X.Y = function(self) endon a table populated after{}- functions in a table literal:
local T = { foo = function() end } X:derive(...)-style tables whose members are assigned afterwards
Environment
@colbymchenry/codegraph1.5.0 (npm global), Windows 11- Behaviour measured on 1.5.0. 1.6.0 not run, but the relevant paths in
mainare unchanged, so I do not expect a difference. - Language: Lua 5.1 (Kahlua dialect, Project Zomboid Build 42)
Workaround we use today
A separate script greps for ^\s*[A-Za-z_][\w.:]*\s*=\s*function\s*\( and prints
the definitions CodeGraph cannot see, so an empty result is not read as "does not
exist". It covers definitions only — it cannot recover the missing call-graph
edges.
Activity
danusha2345 commentedon Sep 4, 2026
This is the shape #1623 fixes:
X.Y = function () … end(andlocal f = function, keyed table fields) become callable nodes, so the caller list stops looking complete when it is not. Re-index after upgrading.colbymchenry commentedon Sep 8, 2026
Fixed in #1778 (lands upstream #1623 by @danusha2345 onto current
main).Linux verify (fail → pass)
Repro
power.lua+client.luaon Linux x64 / Node 22.19.0 with the native kernel:EPR.PowerController::SyncHydroPowerpower.lua:5callers syncHydroPowerpower.lua(same-file, complete-looking)SyncHydroPowercallers SyncHydroPowerclient.luasyncHydroPowerclient.luaCaller chain is now
client.lua → SyncHydroPower → syncHydroPower.Focused suites: 844 passed (extraction + resolution + Lua parity with
CODEGRAPH_KERNEL_EXPECT=1); forced-WASM Lua/Luau: 20 passed.Re-index after upgrading so existing Lua projects pick up the new symbols/edges.
fix(lua): index assignment-style function definitions (#1650) (#1778)