Skip to content

Elixir function nodes are dropped, collapsed, and falsely referenced #2312

Description

@BobbieBarker

This issue and its PR were written by an AI agent working on my behalf. I certify the DCO sign-off on every commit and answer for the change.

The graph answers "where is this defined" well for Elixir. Its answers to "what else touches this" are wrong in five separate ways, and none of them reports an error. I have a working fix for all five and would rather agree the shape with you before you spend review time on it, because one part changes a public string.

Measured on a 970-file Elixir tree, darwin arm64, binaries built from e783f73d.

The five defects

Guarded definitions never reach the graph. def f(x) when guard parses its whole head as one when binary_operator, so extract_elixir_func_def finds no call or identifier where it looks and returns. A function whose clauses all carry guards gets no node at all. Indexing the corpus with a patched binary adds 1,059 function nodes that previously did not exist (20,882 to 21,941) and loses none. search_graph currently answers "No nodes match" for every one of them, which reads identically to dead code.

Node identity collapses. An Elixir qualified name is the file path plus the bare function name, with no module and no arity. Node identity is that string and a collision keeps the largest start_line, so the later definition wins. fetch/1, fetch/2 and fetch/3 become one node holding fetch/3. Three def message/1 across three exception modules in one errors.ex become one node. One real file with 3 modules and 48 def heads yields 2 Function nodes.

Multi-clause functions return one clause. The surviving node is anchored at the last clause, so get_code_snippet returns that clause as the whole function. A two-clause admin?/1 comes back as def admin?(%__MODULE__{}), do: false, so the graph asserts a predicate that always returns false. Across the corpus 5,448 function nodes span a single line; with the fix that falls to 1,345.

Typespecs mint phantom reference edges. @spec foo(t) :: t puts foo(t) in the same call node an invocation occupies, so the unified walk reads a declaration as code and sources a reference from the enclosing module. On a codebase with @spec on every public function, fan_in never reaches 0 and "which exported functions does nothing call" cannot be answered.

There is no containment edge. DEFINES_METHOD was gated on label == "Method", and Elixir defs are labelled Function, so the corpus has 0 such edges today. With the gate widened it has 22,258.

Go and Rust have the identity defect too

Same cause, different surface. Go method receivers are absent from the QN, so (c *Conn) Close and (p *Pool) Close in one package collide. Rust mod inner { pub fn add } drops the inline module segment and collides with a top-level add. Java, Python and TypeScript are unaffected because they qualify through compute_class_qn. Elixir's def walk returns before reaching that machinery and its class_node_types is empty. Happy to do Go and Rust separately once the shape here is settled.

What should the QN look like?

This was the breaking part and the reason I asked first. You accepted this shape, so it is settled: fixing identity means Elixir function QNs become proj.file.Module.name#arity. That changes a public string, bumps CBM_INDEX_FORMAT_VERSION 1 to 2, and requires a reindex. The # fence follows the existing rust_cfg_qualified_name precedent (add#cfg(test)) because . is reserved as the QN separator in about 90 places; only an all-digit tail counts as a fence, so the Rust cfg twin is not mistaken for one.

The alternative was a parallel identity field, which leaves the string alone and is messier inside. I built the first one and you took it, so the second is not on the table.

On DEFINES_METHOD I widened the gate rather than relabelling Elixir defs to Method, because label feeds the FTS rank boost and the search filter and a module is not a class. At your request the Function arm is now gated on cbm_lang_container_is_source_named, so it reaches Elixir and nothing else. It adds no edge today either way: extract_elixir_func_def is the only producer of a (parent_class, label == "Function") pair in the tree. Elixir's container is labelled Class, which is also wrong, and I left it alone.

The split

#2310 is closed. It carried all thirteen commits, and its diff was the pre-split code.

PR commits lines claim
#2370 1 +163/-25 the registry reaches a cfg-fenced function from its bare name
#2371 5 +1206/-11 Elixir guarded heads, multi-clause spans, typespecs walked as code
held 4 ~+1453 the identity keystone together with the MCP bare-name fix
held 1 ~+530 resolve calls with caller container and written arity
held 1 ~+186 a reference records the qualifier it was written under
held 1 ~+323 incremental re-link across an arity change

Order is #2370 and #2371, then the keystone, then the resolver, then the last two. Each held commit goes up as its predecessor merges, so every diff is exactly its own change against the main it targets.

The keystone and the MCP bare-name fix are one PR because the keystone alone breaks the MCP surface. Measured on the same 970-file tree: every one of its 22,259 Elixir function QNs carries a fence, and cbm_store_find_nodes_by_qn_suffix matches '%.' || suffix or = suffix, so a bare name returns 0 rows and get_code_snippet answers nothing for any Elixir function. No test catches it, because the test ships with the fix. The keystone's cost to call resolution is separately small: 248 call sites lost against 3,010 gained, 19 of the losses bare callees.

Two corrections to what I first wrote in this issue. The grouping I offered put commits 8 and 9 before the keystone, which does not build: fix(elixir): extract definitions inside macro and conditional blocks calls the 5-argument extract_elixir_func_def and reads frame.module_qn, both of which the keystone introduces. And commits 1 through 6 are a chain through the shared def-head helper, not independent of each other as I claimed.

State of verification: the full thirteen-commit stack builds and runs 8,226 passed, 0 failed, 10 skipped, with clang-format, the memory-core linter and the language-count contract all clean. I have not yet run each held PR's suite in isolation against the main it will target; that happens as each one goes up. Per-commit measurements are on each PR.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    parsing/qualityGraph extraction bugs, false positives, missing edges

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions