Repository navigation
fix(elixir): functions carry no complexity, fingerprint or line count - #1731
Conversation
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>
|
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. What that means for this PR, concretely:
Things that will genuinely speed it up whenever review does happen:
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. |
|
Your premise checks out, and it explains a whole class of missing data. The test is binding, and the control is the part I want to credit. 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 Three things before merge, and the first one is the real blocker.
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 Your CI here also was not a real signal — |
|
Approved. This is the best-evidenced of the four, and every claim it makes about existing code checks out.
The container-plus-arm convention is real too. Go counts And the tool description does make the promise you quote. 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 Excluding 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 On the @holsee coordinationFlagging 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 Status: cleared and approved. Additive only, +183/-0. That is four defects found, explained and fixed against a real Phoenix application. Thank you. |
|
Same situation as #1730, and thank you for #1721 ( The conflict is confined to
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. |
|
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. |
|
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>
|
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 We pushed six commits on top of your branch; your own commit is unchanged:
For the record, on elixir-lang/elixir (6,703 Elixir functions):
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. |
|
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. |
Refs #1729 (defect 4 of 4).
The bug
Every Elixir
Functionships withcomplexity = 0,cognitive = 0,lines = 0and no MinHash fingerprint. Elixir is therefore invisible to complexity and bottleneck queries, and never participates inSIMILAR_TOor duplicate detection — while thequery_graphtool 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,tryandreceiveare all ordinarycallnodes, distinguished only by the identifier in callee position, and every case/cond/fn/rescue arm is astab_clause.That is also why
elixir_branch_types = {"call"}inlang_specs.chas 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:if,unless,with,for,case,cond,try,receivestab_clause— one per case/cond/fn/rescue/catch/else arm&&,||,and,orwhenguardsContainer and arm are both counted, which is the convention the generic tables already use — Go counts
expression_switch_statementplus eachexpression_case; Python countstry_statementplus eachexcept_clause. Elixir was otherwise scoring below every other language by construction.A
forcomprehension is the only real loop node.Enum.map/reduceiterate too, but they are plain function calls and counting them would makeloop_depthmeaningless.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_blockrather 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 failedBank.Clients.apply_client_filtersscores 34 — exactly its 14stab_clausearms + 6whenguards + 6and+ 1if+ 5or+ 1and+ the innercasecontainerelixir-lang/elixir(6,346.exfunctions) looks like a healthy tail rather than noise: 4,753 above 0, 448 above 10, 108 above 20, 31 above 40clang-format --dry-run --Werrorclean onextract_defs.c.tests/left unformatted, per its exclusion fromLINT_SRCSIndependent 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-clausedefpwith 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