From c8f36262e18318f38387dd60a779eee5b59aa7fd Mon Sep 17 00:00:00 2001 From: Henry Hazan Date: Wed, 19 Aug 2026 02:02:54 +0300 Subject: [PATCH 1/6] fix(elixir): functions carry no complexity, fingerprint or line count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/cbm/extract_defs.c | 143 ++++++++++++++++++++++++++++++++++++ tests/test_extraction.c | 40 ++++++++++ 2 files changed, 183 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index f9854b03e..e2822d446 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -4985,6 +4985,134 @@ static TSNode elixir_call_args(TSNode node) { } // Handle Elixir def/defp/defmacro — extract function definition. +/* True when node `n` spans exactly the text `lit`. Avoids an arena allocation + * per node in the complexity walk, which visits every node of every def. */ +static bool elixir_node_text_is(TSNode n, const char *source, const char *lit) { + if (ts_node_is_null(n)) { + return false; + } + uint32_t s = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + size_t len = strlen(lit); + return (size_t)(e - s) == len && strncmp(source + s, lit, len) == 0; +} + +/* Elixir has no branching NODE TYPES. `if`, `unless`, `case`, `cond`, `with`, + * `for`, `try` and `receive` are ordinary `call` nodes told apart only by the + * identifier in callee position, and every case/cond/fn/rescue arm is a + * `stab_clause`. lang_specs.c cannot express that — the Elixir branch table is + * `{"call"}`, which would score every function call as a decision — and + * cbm_compute_complexity matches on node type, so the generic path is unusable + * and every Elixir function shipped complexity 0. + * + * Counted here: the branching construct itself, every `stab_clause`, the + * short-circuit operators, 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`. Cognitive weighting is Campbell's, + * identical to the generic walk (+1 plus the current nesting depth). */ +static bool elixir_is_branch_call(TSNode call, const char *source) { + if (ts_node_child_count(call) == 0) { + return false; + } + TSNode callee = ts_node_child(call, 0); + if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "identifier") != 0) { + return false; + } + return elixir_node_text_is(callee, source, "if") || + elixir_node_text_is(callee, source, "unless") || + elixir_node_text_is(callee, source, "with") || + elixir_node_text_is(callee, source, "for") || + elixir_node_text_is(callee, source, "case") || + elixir_node_text_is(callee, source, "cond") || + elixir_node_text_is(callee, source, "try") || + elixir_node_text_is(callee, source, "receive"); +} + +/* A comprehension (`for x <- xs, do: ...`) is Elixir's only real loop node. + * Enum.map/reduce iterate too, but they are plain function calls; counting them + * would make loop_depth meaningless, so they are left out. */ +static bool elixir_is_loop_call(TSNode call, const char *source) { + if (ts_node_child_count(call) == 0) { + return false; + } + TSNode callee = ts_node_child(call, 0); + return !ts_node_is_null(callee) && strcmp(ts_node_type(callee), "identifier") == 0 && + elixir_node_text_is(callee, source, "for"); +} + +static bool elixir_is_branch_operator(TSNode node, const char *source) { + TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator")); + return elixir_node_text_is(op, source, "&&") || elixir_node_text_is(op, source, "||") || + elixir_node_text_is(op, source, "and") || elixir_node_text_is(op, source, "or") || + elixir_node_text_is(op, source, "when"); +} + +static void elixir_compute_complexity(TSNode node, const char *source, cbm_complexity_t *out) { + enum { ELIXIR_CX_STACK_CAP = 4096 }; + struct { + TSNode node; + int bdepth; + int ldepth; + int adepth; + } stack[ELIXIR_CX_STACK_CAP]; + memset(out, 0, sizeof(*out)); + int top = 0; + stack[top].node = node; + stack[top].bdepth = 0; + stack[top].ldepth = 0; + stack[top].adepth = 0; + top++; + while (top > 0) { + int cur = --top; + TSNode n = stack[cur].node; + int bdepth = stack[cur].bdepth; + int ldepth = stack[cur].ldepth; + int adepth = stack[cur].adepth; + const char *kind = ts_node_type(n); + bool is_branch = false; + if (strcmp(kind, "stab_clause") == 0) { + is_branch = true; + } else if (strcmp(kind, "call") == 0) { + is_branch = elixir_is_branch_call(n, source); + } else if (strcmp(kind, "binary_operator") == 0) { + is_branch = elixir_is_branch_operator(n, source); + } + int child_b = bdepth; + int child_l = ldepth; + /* `conn.assigns.user` nests as dot(dot(conn)), so consecutive `dot` + * nodes deepen the chain and anything else resets it. */ + int child_a = 0; + if (strcmp(kind, "dot") == 0) { + child_a = adepth + 1; + if (child_a > out->max_access_depth) { + out->max_access_depth = child_a; + } + } + if (is_branch) { + out->cyclomatic++; + out->cognitive += 1 + bdepth; + child_b = bdepth + 1; + } + if (strcmp(kind, "call") == 0 && elixir_is_loop_call(n, source)) { + out->loop_count++; + int d = ldepth + 1; + if (d > out->loop_depth) { + out->loop_depth = d; + } + child_l = d; + } + uint32_t nc = ts_node_child_count(n); + for (int i = (int)nc - 1; i >= 0 && top < ELIXIR_CX_STACK_CAP; i--) { + stack[top].node = ts_node_child(n, (uint32_t)i); + stack[top].bdepth = child_b; + stack[top].ldepth = child_l; + stack[top].adepth = child_a; + top++; + } + } +} + static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro) { CBMArena *a = ctx->arena; TSNode args = elixir_call_args(node); @@ -5017,6 +5145,21 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; def.end_line = ts_node_end_point(node).row + TS_LINE_OFFSET; def.is_exported = (strcmp(macro, "def") == 0 || strcmp(macro, "defmacro") == 0); + def.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET); + cbm_complexity_t cx; + elixir_compute_complexity(node, ctx->source, &cx); + def.complexity = cx.cyclomatic; + def.cognitive = cx.cognitive; + def.loop_count = cx.loop_count; + def.loop_depth = cx.loop_depth; + def.max_access_depth = cx.max_access_depth; + /* Fingerprint off the body, not the whole def call: including the head + * would make every clause of a name look alike. Elixir was the only + * language emitting Functions with no fingerprint at all, so it never + * appeared in similarity or duplicate results. Runs after `lines` — the + * AST profile reads it. */ + TSNode fp_body = cbm_find_child_by_kind(node, "do_block"); + compute_fingerprint(ctx, &def, ts_node_is_null(fp_body) ? node : fp_body); cbm_defs_push(&ctx->result->defs, a, def); } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 447e7c104..ea883bd29 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1005,6 +1005,45 @@ TEST(elixir_function) { PASS(); } +/* Elixir control flow is `call` nodes and `stab_clause` arms, not branch node + * TYPES, so the generic complexity walk scored every Elixir function 0 and the + * fingerprint was never computed at all — no similarity or duplicate detection. */ +TEST(elixir_complexity_and_fingerprint) { + CBMFileResult *r = extract("defmodule Cx do\n" + " def classify(x) do\n" + " case x do\n" + " 0 -> :zero\n" + " n when n > 0 -> :pos\n" + " _ -> :neg\n" + " end\n" + " end\n" + " def plain(a), do: a\n" + "end\n", + CBM_LANG_ELIXIR, "t", "cx.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + CBMDefinition *cls = NULL; + CBMDefinition *plain = NULL; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].name, "classify") == 0) + cls = &r->defs.items[i]; + if (strcmp(r->defs.items[i].name, "plain") == 0) + plain = &r->defs.items[i]; + } + ASSERT_NOT_NULL(cls); + /* The `case` itself + its 3 arms + the second arm's own `when` guard. */ + ASSERT_EQ(5, cls->complexity); + ASSERT(cls->cognitive > 0); + ASSERT(cls->lines > 0); + /* A straight-line body has no decisions but still gets a line count — a + * language must not become nonzero everywhere just by being measured. */ + ASSERT_NOT_NULL(plain); + ASSERT_EQ(0, plain->complexity); + ASSERT(plain->lines > 0); + cbm_free_result(r); + PASS(); +} + /* --- Haskell --- */ TEST(haskell_function) { CBMFileResult *r = extract("add :: Int -> Int -> Int\nadd x y = x + y\n\nmultiply :: Int -> " @@ -5432,6 +5471,7 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); + RUN_TEST(elixir_complexity_and_fingerprint); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); RUN_TEST(erlang_function); From d5cf735ce0ddaa0135f16342657dd99178036b8b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 26 Sep 2026 11:15:23 +0200 Subject: [PATCH 2/6] test(elixir): pin the fingerprint half of the complexity/fingerprint change The carried #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 --- tests/test_extraction.c | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 7bc8a3499..4c7005772 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2749,18 +2749,34 @@ TEST(elixir_complexity_and_fingerprint) { " end\n" " end\n" " def plain(a), do: a\n" + " def summarize(orders, currency) do\n" + " totals = Enum.map(orders, fn order -> order.amount * order.rate end)\n" + " count = length(totals)\n" + " sum = Enum.reduce(totals, 0, fn value, acc -> acc + value end)\n" + " average = if count > 0, do: sum / count, else: 0\n" + " label = String.upcase(currency) <> \" total\"\n" + " %{label: label, sum: sum, count: count, average: average}\n" + " end\n" "end\n", CBM_LANG_ELIXIR, "t", "cx.ex"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); CBMDefinition *cls = NULL; CBMDefinition *plain = NULL; + CBMDefinition *summarize = NULL; for (int i = 0; i < r->defs.count; i++) { if (strcmp(r->defs.items[i].name, "classify") == 0) cls = &r->defs.items[i]; if (strcmp(r->defs.items[i].name, "plain") == 0) plain = &r->defs.items[i]; + if (strcmp(r->defs.items[i].name, "summarize") == 0) + summarize = &r->defs.items[i]; } + /* A body past the MinHash size gate gets a fingerprint (Elixir used to emit + * none at all); a one-expression body stays below the gate and gets none. */ + ASSERT_NOT_NULL(summarize); + ASSERT(summarize->fingerprint_k > 0); + ASSERT_NOT_NULL(summarize->fingerprint); ASSERT_NOT_NULL(cls); /* The `case` itself + its 3 arms + the second arm's own `when` guard. */ ASSERT_EQ(5, cls->complexity); @@ -2771,6 +2787,7 @@ TEST(elixir_complexity_and_fingerprint) { ASSERT_NOT_NULL(plain); ASSERT_EQ(0, plain->complexity); ASSERT(plain->lines > 0); + ASSERT_EQ(0, plain->fingerprint_k); cbm_free_result(r); PASS(); } From b19ac1e923b41ff753eb8d0cf5dc695394f408bc Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 21:30:27 +0100 Subject: [PATCH 3/6] test(elixir): the same body shape under other names shares a fingerprint 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 --- tests/test_extraction.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 4c7005772..7b921b468 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2757,6 +2757,14 @@ TEST(elixir_complexity_and_fingerprint) { " label = String.upcase(currency) <> \" total\"\n" " %{label: label, sum: sum, count: count, average: average}\n" " end\n" + " def tally(items, unit) do\n" + " values = Enum.map(items, fn item -> item.price * item.qty end)\n" + " n = length(values)\n" + " total = Enum.reduce(values, 0, fn v, a -> a + v end)\n" + " mean = if n > 0, do: total / n, else: 0\n" + " tag = String.upcase(unit) <> \" total\"\n" + " %{tag: tag, total: total, n: n, mean: mean}\n" + " end\n" "end\n", CBM_LANG_ELIXIR, "t", "cx.ex"); ASSERT_NOT_NULL(r); @@ -2764,6 +2772,7 @@ TEST(elixir_complexity_and_fingerprint) { CBMDefinition *cls = NULL; CBMDefinition *plain = NULL; CBMDefinition *summarize = NULL; + CBMDefinition *tally = NULL; for (int i = 0; i < r->defs.count; i++) { if (strcmp(r->defs.items[i].name, "classify") == 0) cls = &r->defs.items[i]; @@ -2771,12 +2780,21 @@ TEST(elixir_complexity_and_fingerprint) { plain = &r->defs.items[i]; if (strcmp(r->defs.items[i].name, "summarize") == 0) summarize = &r->defs.items[i]; + if (strcmp(r->defs.items[i].name, "tally") == 0) + tally = &r->defs.items[i]; } /* A body past the MinHash size gate gets a fingerprint (Elixir used to emit * none at all); a one-expression body stays below the gate and gets none. */ ASSERT_NOT_NULL(summarize); ASSERT(summarize->fingerprint_k > 0); ASSERT_NOT_NULL(summarize->fingerprint); + /* Duplicate detection is what the fingerprint exists for: the same body + * shape under other function and variable names gets the same fingerprint. */ + ASSERT_NOT_NULL(tally); + ASSERT_EQ(summarize->fingerprint_k, tally->fingerprint_k); + ASSERT_NOT_NULL(tally->fingerprint); + ASSERT(memcmp(summarize->fingerprint, tally->fingerprint, + (size_t)summarize->fingerprint_k * sizeof(summarize->fingerprint[0])) == 0); ASSERT_NOT_NULL(cls); /* The `case` itself + its 3 arms + the second arm's own `when` guard. */ ASSERT_EQ(5, cls->complexity); From aa3161fd817e646dbadb9ff0cfd6a001b0b39f91 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 21:31:06 +0100 Subject: [PATCH 4/6] fix(elixir): grow the complexity walk stack instead of capping it 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 #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 --- internal/cbm/extract_defs.c | 27 +++++++++++++++++++++++---- tests/test_extraction.c | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index c9276df5f..1acd9d93b 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9888,14 +9888,22 @@ static bool elixir_is_branch_operator(TSNode node, const char *source) { elixir_node_text_is(op, source, "when"); } +/* The pending frames live on the same growable stack as cbm_compute_complexity: + * a small on-stack first chunk that spills to the heap through + * cbm_walk_stack_reserve, so a wide body is never truncated and no thread pays + * for a large fixed array. If the stack cannot grow, every metric reports + * CBM_WALK_METRIC_UNAVAILABLE instead of a silently low count. */ static void elixir_compute_complexity(TSNode node, const char *source, cbm_complexity_t *out) { - enum { ELIXIR_CX_STACK_CAP = 4096 }; - struct { + enum { ELIXIR_CX_STACK_INLINE = 512 }; + struct elixir_cx_frame { TSNode node; int bdepth; int ldepth; int adepth; - } stack[ELIXIR_CX_STACK_CAP]; + }; + struct elixir_cx_frame inline_stack[ELIXIR_CX_STACK_INLINE]; + struct elixir_cx_frame *stack = inline_stack; + int cap = ELIXIR_CX_STACK_INLINE; memset(out, 0, sizeof(*out)); int top = 0; stack[top].node = node; @@ -9943,7 +9951,17 @@ static void elixir_compute_complexity(TSNode node, const char *source, cbm_compl child_l = d; } uint32_t nc = ts_node_child_count(n); - for (int i = (int)nc - 1; i >= 0 && top < ELIXIR_CX_STACK_CAP; i--) { + if (!cbm_walk_stack_reserve((void **)&stack, &cap, top + (int)nc, + sizeof(struct elixir_cx_frame), inline_stack, + "elixir_compute_complexity")) { + out->cyclomatic = CBM_WALK_METRIC_UNAVAILABLE; + out->cognitive = CBM_WALK_METRIC_UNAVAILABLE; + out->loop_count = CBM_WALK_METRIC_UNAVAILABLE; + out->loop_depth = CBM_WALK_METRIC_UNAVAILABLE; + out->max_access_depth = CBM_WALK_METRIC_UNAVAILABLE; + break; + } + for (int i = (int)nc - 1; i >= 0; i--) { stack[top].node = ts_node_child(n, (uint32_t)i); stack[top].bdepth = child_b; stack[top].ldepth = child_l; @@ -9951,6 +9969,7 @@ static void elixir_compute_complexity(TSNode node, const char *source, cbm_compl top++; } } + cbm_walk_stack_release(stack, inline_stack); } static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro, diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 7b921b468..8e7231ccd 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2810,6 +2810,40 @@ TEST(elixir_complexity_and_fingerprint) { PASS(); } +/* The Elixir complexity walk keeps its pending nodes on the growable stack + * cbm_compute_complexity uses. A fixed 4096-frame stack dropped the FIRST + * children of any wider body (they are pushed last-to-first), so the largest + * functions silently under-reported. This body is wider than that old cap. */ +enum { ELIXIR_WIDE_STMTS = 5000 }; + +TEST(elixir_complexity_wide_body_exceeds_old_stack_cap) { + static const char head[] = "defmodule Wide do\n def wide(x) do\n"; + static const char if_stmt[] = " if x, do: :a\n"; + static const char for_stmt[] = " for y <- x, do: y\n"; + static const char tail[] = " end\nend\n"; + size_t cap = sizeof(head) + (size_t)ELIXIR_WIDE_STMTS * sizeof(for_stmt) + sizeof(tail); + char *src = malloc(cap); + ASSERT_NOT_NULL(src); + size_t pos = (size_t)snprintf(src, cap, "%s", head); + for (int i = 0; i < ELIXIR_WIDE_STMTS; i++) { + pos += (size_t)snprintf(src + pos, cap - pos, "%s", (i % 2 == 0) ? if_stmt : for_stmt); + } + snprintf(src + pos, cap - pos, "%s", tail); + CBMFileResult *r = extract(src, CBM_LANG_ELIXIR, "t", "wide.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *d = elixir_first_def(r, "wide"); + ASSERT_NOT_NULL(d); + /* every `if` and every `for` is one branching call at nesting depth 0 */ + ASSERT_EQ(ELIXIR_WIDE_STMTS, d->complexity); + ASSERT_EQ(ELIXIR_WIDE_STMTS, d->cognitive); + ASSERT_EQ(ELIXIR_WIDE_STMTS / 2, d->loop_count); + ASSERT_EQ(1, d->loop_depth); + cbm_free_result(r); + free(src); + PASS(); +} + /* --- Haskell --- */ TEST(haskell_function) { CBMFileResult *r = extract("add :: Int -> Int -> Int\nadd x y = x + y\n\nmultiply :: Int -> " @@ -14615,6 +14649,7 @@ SUITE(extraction) { RUN_TEST(extract_elixir_guarded_def_head_guard_usages); RUN_TEST(elixir_channels_pubsub_and_handle_in); RUN_TEST(elixir_complexity_and_fingerprint); + RUN_TEST(elixir_complexity_wide_body_exceeds_old_stack_cap); RUN_TEST(haskell_function); RUN_TEST(haskell_infix_definition_name); RUN_TEST(ocaml_function); From 2caf51a4c6556a9d60d330758b1397ef7b804d95 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 21:31:23 +0100 Subject: [PATCH 5/6] fix(elixir): a folded multi-clause function carries the metrics of every clause Since #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 --- internal/cbm/extract_defs.c | 39 +++++++++++++++++++++++++--- tests/test_extraction.c | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 1acd9d93b..8f759e035 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9714,6 +9714,24 @@ static TSNode elixir_call_args(TSNode node) { return args; } +/* Combine one folded clause's metric with the function's value so far. A clause + * whose walk could not finish reports CBM_WALK_METRIC_UNAVAILABLE, and so does + * the folded function: a sum that skipped that clause would read low on exactly + * the largest functions. */ +static int elixir_fold_metric_sum(int acc, int clause) { + if (acc < 0 || clause < 0) { + return CBM_WALK_METRIC_UNAVAILABLE; + } + return acc + clause; +} + +static int elixir_fold_metric_max(int acc, int clause) { + if (acc < 0 || clause < 0) { + return CBM_WALK_METRIC_UNAVAILABLE; + } + return clause > acc ? clause : acc; +} + // Fold one more clause of an Elixir function into the def already pushed for // the previous clause. Every clause is its own `def` call, and an Elixir QN // carries neither module nor arity, so all of them compute one qualified name @@ -9792,9 +9810,10 @@ static TSNode elixir_call_args(TSNode node) { // name, where the pre-patch last-clause-wins record reported a publicly // callable name as unexported. Verified against the source in every one: the // new value is the correct one. +// `cx` is this clause's own complexity; a folded clause adds it to the function. // Returns true when the clause folded. static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, TSNode node, - uint32_t end_line, bool is_exported) { + uint32_t end_line, bool is_exported, const cbm_complexity_t *cx) { if (!qn || ctx->result->defs.count <= 0 || ts_node_is_null(scope)) { return false; } @@ -9819,6 +9838,18 @@ static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, prev->end_line = end_line; } prev->is_exported = prev->is_exported || is_exported; + /* One node now stands for every clause, so its metrics cover every clause + * rather than the first: branch and loop counts add up, the nesting depths + * take the deepest clause, and each clause past the first is one more + * dispatch decision. A multi-clause function with no branch in any body is + * still a decision tree over its heads. `lines` follows the widened span. */ + int cyclomatic = elixir_fold_metric_sum(prev->complexity, cx->cyclomatic); + prev->complexity = cyclomatic < 0 ? cyclomatic : cyclomatic + 1; + prev->cognitive = elixir_fold_metric_sum(prev->cognitive, cx->cognitive); + prev->loop_count = elixir_fold_metric_sum(prev->loop_count, cx->loop_count); + prev->loop_depth = elixir_fold_metric_max(prev->loop_depth, cx->loop_depth); + prev->max_access_depth = elixir_fold_metric_max(prev->max_access_depth, cx->max_access_depth); + prev->lines = (int)(prev->end_line - prev->start_line + TS_LINE_OFFSET); return true; } @@ -10010,7 +10041,9 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char uint32_t start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; uint32_t end_line = ts_node_end_point(node).row + TS_LINE_OFFSET; bool is_exported = (strcmp(macro, "def") == 0 || strcmp(macro, "defmacro") == 0); - bool folded = fold_elixir_clause(ctx, qn, *scope, node, end_line, is_exported); + cbm_complexity_t cx; + elixir_compute_complexity(node, ctx->source, &cx); + bool folded = fold_elixir_clause(ctx, qn, *scope, node, end_line, is_exported, &cx); *scope = ts_node_parent(node); if (folded) { return; @@ -10026,8 +10059,6 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char def.end_line = end_line; def.is_exported = is_exported; def.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET); - cbm_complexity_t cx; - elixir_compute_complexity(node, ctx->source, &cx); def.complexity = cx.cyclomatic; def.cognitive = cx.cognitive; def.loop_count = cx.loop_count; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 8e7231ccd..29fd1cc3c 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2810,6 +2810,57 @@ TEST(elixir_complexity_and_fingerprint) { PASS(); } +/* The clauses of a multi-clause function fold into one Function node, so that + * node's metrics must cover every clause, not just the first: branch, cognitive + * and loop counts add up, loop_depth and max_access_depth take the deepest + * clause, every clause past the first is one more dispatch decision, and + * `lines` is the folded span. `single` repeats the second clause on its own as + * the per-clause reference: it is not folded, so it carries no dispatch charge. */ +TEST(elixir_folded_clause_metrics_cover_every_clause) { + CBMFileResult *r = extract("defmodule Route do\n" /* 1 */ + " def pick(0), do: Map.new()\n" /* 2 */ + " def pick(n) when n > 0 do\n" /* 3 */ + " for x <- [n], do: Map.new([{x, x}])\n" /* 4 */ + " end\n" /* 5 */ + " def pick(rows) do\n" /* 6 */ + " for row <- rows, do: (for x <- row, do: x)\n" /* 7 */ + " end\n" /* 8 */ + " def single(n) when n > 0 do\n" /* 9 */ + " for x <- [n], do: Map.new([{x, x}])\n" /* 10 */ + " end\n" /* 11 */ + "end\n", /* 12 */ + CBM_LANG_ELIXIR, "t", "route.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_defs_named(r, "Function", "pick")); + const CBMDefinition *one = elixir_first_def(r, "single"); + ASSERT_NOT_NULL(one); + /* `when` + `for`, each at nesting 0; one comprehension; Map.new is one dot */ + ASSERT_EQ(2, one->complexity); + ASSERT_EQ(2, one->cognitive); + ASSERT_EQ(1, one->loop_count); + ASSERT_EQ(1, one->loop_depth); + ASSERT_EQ(1, one->max_access_depth); + ASSERT_EQ(3, one->lines); + + const CBMDefinition *pick = elixir_first_def(r, "pick"); + ASSERT_NOT_NULL(pick); + ASSERT_EQ(2, (int)pick->start_line); + ASSERT_EQ(8, (int)pick->end_line); + /* clauses 0 + 2 + 2, plus 2 for the two clauses past the first */ + ASSERT_EQ(6, pick->complexity); + /* 0 + 2 + (1 + 2): the inner comprehension sits one level deep */ + ASSERT_EQ(5, pick->cognitive); + ASSERT_EQ(3, pick->loop_count); + /* the deepest clause, not the sum of the clauses (that would be 3) */ + ASSERT_EQ(2, pick->loop_depth); + /* two clauses with one dot each: the deepest is 1, the sum would be 2 */ + ASSERT_EQ(1, pick->max_access_depth); + ASSERT_EQ(7, pick->lines); + cbm_free_result(r); + PASS(); +} + /* The Elixir complexity walk keeps its pending nodes on the growable stack * cbm_compute_complexity uses. A fixed 4096-frame stack dropped the FIRST * children of any wider body (they are pushed last-to-first), so the largest @@ -14649,6 +14700,7 @@ SUITE(extraction) { RUN_TEST(extract_elixir_guarded_def_head_guard_usages); RUN_TEST(elixir_channels_pubsub_and_handle_in); RUN_TEST(elixir_complexity_and_fingerprint); + RUN_TEST(elixir_folded_clause_metrics_cover_every_clause); RUN_TEST(elixir_complexity_wide_body_exceeds_old_stack_cap); RUN_TEST(haskell_function); RUN_TEST(haskell_infix_definition_name); From 3b33babd7c0b649942ad6728a483a492d1a81bb2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 21:31:39 +0100 Subject: [PATCH 6/6] style(elixir): put the definition comment back above extract_elixir_func_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 --- internal/cbm/extract_defs.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 8f759e035..e6ba01d7e 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9853,9 +9853,6 @@ static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, return true; } -// Handle Elixir def/defp/defmacro — extract function definition. `scope` is the -// module body the previous clause was extracted from, and is updated to this -// def's own module body; see fold_elixir_clause for what it bounds. /* True when node `n` spans exactly the text `lit`. Avoids an arena allocation * per node in the complexity walk, which visits every node of every def. */ static bool elixir_node_text_is(TSNode n, const char *source, const char *lit) { @@ -10003,6 +10000,9 @@ static void elixir_compute_complexity(TSNode node, const char *source, cbm_compl cbm_walk_stack_release(stack, inline_stack); } +// Handle Elixir def/defp/defmacro — extract function definition. `scope` is the +// module body the previous clause was extracted from, and is updated to this +// def's own module body; see fold_elixir_clause for what it bounds. static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro, TSNode *scope) { CBMArena *a = ctx->arena;