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() end
client.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_declaration and function_call. A statement-level
assignment_statement — which is what X.Y = function() end parses to
without local — has no branch, so no node is minted.
extract_variable filters the name list to identifiers only:
vl.named_children(&mut c).filter(|n| n.kind() == "identifier"). A dotted
target is a dot_index_expression and is dropped. That branch also mints kind
"variable", not "function".
maybe_capture_fn_refs does handle assignment_statement, but only in rhs
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) end on 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/codegraph 1.5.0 (npm global), Windows 11
- Behaviour measured on 1.5.0. 1.6.0 not run, but the relevant paths in
main are 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.
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() ... endis equivalent tofunction 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.luaclient.luaExpected:
EPR.PowerController.SyncHydroPoweris a symbol, and the callersreachable from
syncHydroPowerincludeclient.lua.Actual: it is not a symbol, and
codegraph node syncHydroPowerreports"5 callers, all in power.lua" — zero cross-file callers. The real external
caller in
client.luais 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:function_declaration,type_definition(Luau),
variable_declarationandfunction_call. A statement-levelassignment_statement— which is whatX.Y = function() endparses towithout
local— 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 dottedtarget is a
dot_index_expressionand is dropped. That branch also mints kind"variable", not"function".maybe_capture_fn_refsdoes handleassignment_statement, but only inrhsmode, 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.luahasM.assigned = function(z)(line 67) and
local anonAssigned = function(v)(line 28).__tests__/kernel-lua-parity.test.tsmakes no assertion about either. Itcompares the Rust kernel against the WASM extractor for equality
(
expect(k.nodes).toEqual(w.nodes)), so both being blind in the same way passesas parity. A single assertion — "
M.assignedappears 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:
EPR_VanillaOverrides.luaThe 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{}local T = { foo = function() end }X:derive(...)-style tables whose members are assigned afterwardsEnvironment
@colbymchenry/codegraph1.5.0 (npm global), Windows 11mainare unchanged, so I do not expect a difference.Workaround we use today
A separate script greps for
^\s*[A-Za-z_][\w.:]*\s*=\s*function\s*\(and printsthe 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.