Skip to content

fix(elixir): functions carry no complexity, fingerprint or line count - #1731

Merged
DeusData merged 7 commits into
DeusData:mainfrom
henry-hz:upstream-pr/elixir-complexity
Nov 1, 2026
Merged

DeusData merged 7 commits into
DeusData:mainfrom
henry-hz:upstream-pr/elixir-complexity

Conversation

@henry-hz

Copy link
Copy Markdown
Contributor

Refs #1729 (defect 4 of 4).

The bug

Every Elixir Function ships with complexity = 0, cognitive = 0, lines = 0 and no MinHash fingerprint. Elixir is therefore invisible to complexity and bottleneck queries, and never participates in SIMILAR_TO or duplicate detection — while the query_graph tool description states that "every Function and Method node carries queryable complexity properties".

Measured before this change on a 829-file Phoenix application: 2 of 4,504 functions had complexity > 0, and 7 had a fingerprint. Both figures came entirely from the repo's TypeScript and Python sources.

Why the generic path cannot be reused

cbm_compute_complexity() matches on node type. Elixir has no branching node types — if, unless, case, cond, with, for, try and receive are all ordinary call nodes, distinguished only by the identifier in callee position, and every case/cond/fn/rescue arm is a stab_clause.

That is also why elixir_branch_types = {"call"} in lang_specs.c has never been usable: it would score every function call as a decision.

The fix

elixir_compute_complexity() — one walk, same Campbell cognitive weighting as the generic version, counting:

  • the branching construct itself: if, unless, with, for, case, cond, try, receive
  • every stab_clause — one per case/cond/fn/rescue/catch/else arm
  • the short-circuit operators &&, ||, and, or
  • when guards

Container and arm are both counted, which is the convention the generic tables already use — Go counts expression_switch_statement plus each expression_case; Python counts try_statement plus each except_clause. Elixir was otherwise scoring below every other language by construction.

A for comprehension is the only real loop node. Enum.map/reduce iterate too, but they are plain function calls and counting them would make loop_depth meaningless.

A straight-line single-clause function still scores 0, exactly like every other language's branch-free function — measuring a language must not make every function nonzero.

The fingerprint is taken from the do_block rather than the whole def call, so the head does not make every clause of a name look alike.

Verification

  • make -f Makefile.cbm test-focused TEST_SUITES="extraction registry lang_contract grammar_regression grammar_labels repro_language_registry repro_call_node_manifest" → 388 passed, 0 failed
  • hand-checked against source: Bank.Clients.apply_client_filters scores 34 — exactly its 14 stab_clause arms + 6 when guards + 6 and + 1 if + 5 or + 1 and + the inner case container
  • distribution on elixir-lang/elixir (6,346 .ex functions) looks like a healthy tail rather than noise: 4,753 above 0, 448 above 10, 108 above 20, 31 above 40
  • clang-format --dry-run --Werror clean on extract_defs.c. tests/ left unformatted, per its exclusion from LINT_SRCS

Independent of #1721 and #1730.

One note for @holsee, since this touches the same function as D3: this is written against upstream's current extract_elixir_func_def(), which emits one node per clause. If name/arity identity lands and clauses merge into a single Function, the per-clause metrics should be summed and the clause count itself charged as dispatch complexity — a multi-clause defp with no body branches is still a decision tree. Happy to rework this on top of that instead if the ordering suits you better.

Submitted as a bug fix under the CONTRIBUTING exception for focused bug fixes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DpDDX9sFC16mQ9U9wQ3WNd

Every Elixir Function shipped with complexity 0, cognitive 0, lines 0 and no
MinHash fingerprint, so Elixir was invisible to complexity and bottleneck
queries and never participated in SIMILAR_TO or duplicate detection — while the
query_graph tool description states that "every Function and Method node carries
queryable complexity properties".

The generic path cannot be reused. cbm_compute_complexity() matches on node
TYPE, but Elixir has no branching node types: `if`, `unless`, `case`, `cond`,
`with`, `for`, `try` and `receive` are ordinary `call` nodes distinguished only
by the identifier in callee position, and every case/cond/fn/rescue arm is a
`stab_clause`. That is also why elixir_branch_types is `{"call"}` in
lang_specs.c and has never been usable — it would score every function call as a
decision.

elixir_compute_complexity() is the Elixir-shaped equivalent, one walk with the
same Campbell cognitive weighting as the generic version. It counts the
branching construct itself, every stab_clause, the short-circuit operators
(&&, ||, and, or) and `when` guards. Container and arm are both counted, which
is the convention the generic tables already use: Go counts
expression_switch_statement plus each expression_case, Python counts
try_statement plus each except_clause. A `for` comprehension is the only real
loop node; Enum.map/reduce are plain calls and counting them would make
loop_depth meaningless.

A straight-line single-clause function still scores 0, exactly like every other
language's branch-free function.

The fingerprint is taken from the do_block rather than the whole def call, so
the head does not make every clause of a name look alike.

Signed-off-by: Henry Hazan <henry@teramine.io>
@henry-hz
henry-hz requested a review from DeusData as a code owner August 19, 2026 06:55
@github-actions

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@DeusData

Copy link
Copy Markdown
Owner

Your premise checks out, and it explains a whole class of missing data. lang_specs.c:503 declares elixir_branch_types[] = {"call", NULL} — so the generic complexity walker, which counts branch nodes by type, sees every Elixir expression as a branch and is useless for this language. That is why every Elixir function has shipped complexity = 0, and why none of them participate in similarity or duplicate detection. Not a small gap.

The test is binding, and the control is the part I want to credit. ASSERT_EQ(5, cls->complexity) fails on main where everything is 0. But ASSERT_EQ(0, plain->complexity) is the assertion that makes the first one mean something: it pins that measuring a language must not make every function non-zero. A fix that just started emitting numbers would pass the first assertion and fail the second. That is the right shape.

Answering your direction question directly: land this as-is, do not rebuild it on the clause-merging work. I checked — there is no open PR touching extract_elixir_func_def, and nothing from that workstream is in flight. Rebuilding on top of unmerged work would mean blocking a self-contained fix on something that does not exist yet. Whoever lands second does the rebase; that is the cheaper order. Thank you for asking rather than assuming.

Three things before merge, and the first one is the real blocker.

  1. The fingerprint is not tested at all. The test is named elixir_complexity_and_fingerprint, the PR title says "no complexity, fingerprint or line count", and the body claims the fingerprint fix — but the assertions are complexity, cognitive, lines and the control. Nothing touches the fingerprint. So compute_fingerprint(ctx, &def, ts_node_is_null(fp_body) ? node : fp_body) could be deleted and the suite would stay green.

    That matters here more than usual: this project has been through an audit specifically for tests that pass without proving the fix exists, and "green means fixed" is the standard the suite is held to. Please assert the fingerprint — non-zero on a real function, and ideally equal for two structurally identical functions with different names, since duplicate detection is the thing it exists for.

  2. elixir_compute_complexity puts 192 KB on the stack. stack[ELIXIR_CX_STACK_CAP] is 4096 entries of {TSNode, int, int, int}; TSNode is 32 bytes, so each entry is 48 and the array is exactly 196,608 bytes in one frame. I measured it rather than estimating.

    This is not theoretical for us: three deep-recursion suites already overflow their thread stacks on our arm64 sanitizer container, and worker threads do not get the main thread's 8 MB. A heap allocation, or a much smaller initial array that grows, would take the risk away. The arena is already threaded through ctx if you want somewhere to put it.

  3. Hitting the cap silently under-reports. The push guard is i >= 0 && top < ELIXIR_CX_STACK_CAP, so once the stack is full the remaining children are dropped and the function returns a smaller complexity with no signal. A metric that quietly reads low on exactly the largest functions — the ones you most want flagged — is worse than one that refuses to answer. Either grow rather than drop, or record that the value was truncated.

None of those are hard, and the shape of the change underneath them is right. Ping me when they are in.

One heads-up spanning your three PRs. Production files are disjoint, so nothing here interacts with #1721 or #1730. But all three insert into tests/test_extraction.c at the same two anchors — right after TEST(elixir_function) closes and right after RUN_TEST(elixir_function);. GitHub shows all three MERGEABLE because it compares each against main alone; expect the first merge to flip the other two to CONFLICTING at both points.

Your CI here also was not a real signal — test was skipped and codeql-gate shows our known gate-timeout pattern rather than a finding. I have re-run it.

@DeusData DeusData added bug Something isn't working parsing/quality Graph extraction bugs, false positives, missing edges priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. labels Aug 24, 2026
@DeusData

DeusData commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Approved. This is the best-evidenced of the four, and every claim it makes about existing code checks out.

elixir_branch_types[] = {"call", NULL} is real — lang_specs.c:523. You are right that it has never been usable: scoring every call node as a decision would make complexity a function-call count. A configuration entry that exists, looks plausible and cannot ever be switched on is worse than an absent one, because it reads as coverage.

The container-plus-arm convention is real too. Go counts expression_switch_statement at :187 and expression_case at :190. Following the existing convention rather than inventing a scoring rule for Elixir is what makes these numbers comparable across languages — and you are right that counting only one of the two would have left Elixir scoring below every other language by construction.

And the tool description does make the promise you quote. mcp.c:478: "COMPLEXITY / BOTTLENECKS: every Function and Method node carries queryable complexity". That was false for Elixir, and this makes it true. Fixing the code so the documentation stops lying is the right direction — the same shape as the parse-audit work landing elsewhere in this queue.

The judgement calls are the good part

"A straight-line single-clause function still scores 0, exactly like every other language's branch-free function — measuring a language must not make every function nonzero." That is the sentence that separates a real metric from a busy one. It would have been easy to charge something per clause and produce a distribution that looked alive while meaning nothing.

Taking the fingerprint from the do_block rather than the whole def call is the equivalent instinct for similarity: including the head would make every clause of a name resemble every other, which is precisely the false positive SIMILAR_TO must not produce.

Excluding Enum.map/reduce from loop depth for the stated reason — they are plain calls, and counting them would make loop_depth meaningless — is the same discipline a third time.

And your verification is the shape I wish more perf and metric changes had. A hand-checked function whose score decomposes exactly (34 = 14 arms + 6 guards + 6 and + 1 if + 5 or + 1 and + the inner case) proves the walk, and the distribution across 6,346 real functions — 4,753 above 0, 448 above 10, 31 above 40 — proves it produces a tail rather than noise. Either alone would be weaker than both together.

On the @holsee coordination

Flagging the interaction with the name/arity identity work, and offering to rework on top of it, is exactly right. For the record so it is not lost: if clauses do merge into one Function, summing the per-clause metrics and charging the clause count itself as dispatch complexity is the correct treatment — a multi-clause defp with no body branches genuinely is a decision tree, and dropping to a single clause's score would understate it. No need to reorder on my account; whichever lands first, the other rebases.

Status: cleared and approved. Additive only, +183/-0. main moved three times yesterday, so I will update this branch and re-run before merging — nothing needed from you.

That is four defects found, explained and fixed against a real Phoenix application. Thank you.

@DeusData

DeusData commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Same situation as #1730, and thank you for #1721 (ffb5ebe9e8ad) — that merge is what turned this one conflicting.

The conflict is confined to tests/test_extraction.c and is a pure both-sides-append at the same spot:

  • origin/main appended TEST(elixir_call_string_argument) where this PR appends TEST(elixir_complexity_and_fingerprint).
  • Resolution: keep both test blocks, and keep both RUN_TEST(elixir_call_string_argument); and RUN_TEST(elixir_complexity_and_fingerprint); at the bottom.

internal/cbm/extract_defs.c auto-merges cleanly (it picked up the Ruby superclass fix from #1709 today; no overlap with your hunk).

Because #1730 appends at the same spot too, the second of the pair to land will need one more small rebase after the first merges — see my note on #1730 for the option of stacking this one on that branch to save a round-trip. Merge on green either way.

@DeusData

Copy link
Copy Markdown
Owner

Thank you again, Henry. A quick status note so this thread does not look forgotten: #2371 reshapes the same function this change extends (it folds a multi-clause function into one node spanning all of its clauses), and we agreed there that it lands first. Your complexity and fingerprint work will then be carried on top of it on our side, with the per-clause metrics summed over the folded function and the clause count charged as dispatch complexity, which is the treatment you proposed in your own description. Nothing is needed from you; we will post the exact changes here when they are ready.

@DeusData

Copy link
Copy Markdown
Owner

Thank you again for the complexity and fingerprint work. The agreed order remains #2371 first, with us carrying this change on top and preserving the per-clause metrics. Nothing is needed from you at this point. Sorry for the delay—I’ll follow up soon.

Brings the branch up to f6fb3e0, which carries DeusData#2371 (guarded def heads,
the multi-clause fold and typespec attributes) and DeusData#1730.

Two files conflicted, both at spots where main rewrote the Elixir
definition code. Each is rebuilt as the main version of the file plus the
lines this branch adds, at the same place; no conflict side is
concatenated:

- internal/cbm/extract_defs.c: the complexity helpers sit between the
  definition comment and extract_elixir_func_def as before, and the
  metrics plus fingerprint block follows def.is_exported in the unfolded
  path.
- tests/test_extraction.c: TEST(elixir_complexity_and_fingerprint) and
  its RUN_TEST go just before the Haskell block, after the Elixir tests
  main added.

Per-file patch-id (git diff -U0 | git patch-id --stable) of this merge
against origin/main equals the branch change against its old base:
extract_defs.c b591a7bc, test_extraction.c 7b507156. The merge removes no
line of either file.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…change

The carried DeusData#1731 test asserted complexity and line counts but never the fingerprint, which is half of what the change adds. A body past the MinHash size gate must now carry one, and a one-expression body must not. Raised in the 2026-08-20 review.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The 2026-08-20 review asked for the fingerprint to be equal for two
structurally identical functions with different names, since duplicate
detection is what it exists for. tally repeats the body of summarize with
every function and variable name changed, and both must carry the same
MinHash values. The fingerprint hashes normalised leaf token kinds, so names
never enter it.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
elixir_compute_complexity kept its pending frames in one fixed 4096-entry
array (48 bytes each, 196,608 bytes in a single stack frame) and stopped
pushing children once it was full. Children are pushed last to first, so a
wider body lost its FIRST children and the function reported a lower
complexity with no signal, on exactly the largest functions. Worker threads
do not get the 8 MB main-thread stack, so the array was a risk of its own.

It now uses the walker pattern cbm_compute_complexity has used since DeusData#2375:
a 512-frame on-stack first chunk that spills to the heap through
cbm_walk_stack_reserve (memory core, class EXTRACT) and is released with
cbm_walk_stack_release. If the stack cannot grow, every metric reports
CBM_WALK_METRIC_UNAVAILABLE instead of an undercount. This answers items 2
and 3 of the 2026-08-20 review.

The new test builds a 5000-statement body of alternating if and for. The
fixed stack reports complexity 4095 for it instead of 5000.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…ery clause

Since DeusData#2371 the clauses of a multi-clause function fold into one Function
node whose span covers all of them, but only the first clause was measured:
the folded node kept the complexity, cognitive, loop and access metrics and
the line count of that clause while its span grew.

fold_elixir_clause now receives the metrics of each clause it folds and
adds them to the node: cyclomatic, cognitive and loop_count add up,
loop_depth and max_access_depth take the deepest clause, every clause past
the first adds one dispatch decision to cyclomatic, and lines is recomputed
from the folded span. A clause whose walk reports
CBM_WALK_METRIC_UNAVAILABLE makes the folded value unavailable rather than a
partial sum. This is the treatment proposed in the PR description and
agreed in the review thread.

Indexed elixir-lang/elixir (669f8481, 6703 Elixir Function nodes) against
the merge parent: the 2212 folded functions change, the 4491 single-clause
functions keep identical metrics, Functions with complexity above 0 go from
3412 to 4785, and every node now has lines equal to its span (4491 did
before).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…unc_def

The complexity helpers were inserted between the comment that documents
extract_elixir_func_def and the function itself, so that comment read as
the description of elixir_node_text_is. Move it back above the function it
documents; the comment and signature are again byte-identical to main. No
code change.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData

Copy link
Copy Markdown
Owner

Thank you, Henry, for your patience with this one, and for the work underneath it. Your diagnosis was right from the start: Elixir branches are call nodes and stab_clause arms rather than branch node types, so the generic walker could never see them, and your walk counts container and arm the same way the other languages already do. The control assertion (a straight-line function must still score 0) and taking the fingerprint from the do_block are exactly the judgement calls that make these numbers trustworthy. You also proposed, in your own description, how the metrics should behave once the clauses of a function merge into one node. Now that #2371 has landed, that is the treatment we implemented.

We pushed six commits on top of your branch; your own commit is unchanged:

  1. Merge origin/main (2e6ab84f). This brings in fix(elixir): guarded def heads, multi-clause spans, and typespecs walked as code #2371, which folds the clauses of a multi-clause function into one Function node and rewrote the code around extract_elixir_func_def. Both files conflicted only where main rewrote that code. Each is rebuilt as the main version plus exactly the lines your commit adds, at the same places.

  2. test(elixir): pin the fingerprint half of the complexity/fingerprint change (d5cf735c). This is the first point of the 2026-08-20 review. A body past the MinHash size gate must carry a fingerprint and a one-expression body must not. Without your compute_fingerprint call this test fails.

  3. test(elixir): the same body shape under other names shares a fingerprint (b19ac1e9). This is the rest of that point. tally repeats the body of summarize with every function and variable name changed, and must get the identical fingerprint, which is what duplicate detection relies on.

  4. fix(elixir): grow the complexity walk stack instead of capping it (aa3161fd). These are points 2 and 3 of the review. The fixed 4096-entry stack array (about 192 KB in a single frame) is replaced by the walker pattern the generic cbm_compute_complexity uses: a 512-frame on-stack chunk that grows on the heap through cbm_walk_stack_reserve. If it cannot grow, every metric reports CBM_WALK_METRIC_UNAVAILABLE instead of a silently low count. A new test with a 5000-statement body reports 4095 with the old stack and exactly 5000 now.

  5. fix(elixir): a folded multi-clause function carries the metrics of every clause (2caf51a4). With the clauses folded into one node, only the first clause was being measured. Now:

    • cyclomatic, cognitive and loop counts add up over the clauses;
    • loop depth and access depth take the deepest clause;
    • every clause past the first adds one dispatch decision;
    • lines follows the folded span;
    • a clause whose metrics are unavailable makes the folded value unavailable too.

    On elixir-lang/elixir this changes the 2,212 folded functions and leaves the 4,491 single-clause functions exactly as they were.

  6. style(elixir): put the definition comment back above extract_elixir_func_def (3b33babd). The new helpers had landed between that comment and the function it documents. It is back where it belongs, with no code change.

For the record, on elixir-lang/elixir (6,703 Elixir functions):

main this branch
complexity above 0 0 4,785
line count equal to the span 0 6,703 (all)
MinHash fingerprint 0 1,904
SIMILAR_TO edges 14 103

Because we pushed to your branch, please pull before pushing again. We will merge as soon as CI is green.

Thank you for the whole #1729 series. This last piece is what finally makes Elixir show up in complexity, bottleneck and similarity queries.

@DeusData
DeusData merged commit f159b4c into DeusData:main Nov 1, 2026
26 checks passed
@DeusData

DeusData commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Merged as f159b4c - thank you, Henry! Elixir functions now carry cyclomatic and cognitive complexity, loop metrics, a MinHash fingerprint and a line count, and a multi-clause function folded into one node carries the metrics of all its clauses. On elixir-lang/elixir that takes complexity coverage from 0 to 4,785 functions and SIMILAR_TO edges from 14 to 103. Thank you for the diagnosis that made it possible, and for your patience while #1730 and #2371 landed first.

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

Labels

bug Something isn't working parsing/quality Graph extraction bugs, false positives, missing edges priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants