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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-31 21:31:06 +01:00
1 parent b19ac1e923
commit aa3161fd81
2 files changed
+58 -4

No files matched your search

+23 -4
View File
@@ -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,
+35
View File
@@ -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);