Skip to content

Lua: assignment-style function definitions (X.Y = function()) are not indexed — and the resulting caller list looks complete #1650

Description

@joshi2022

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions