From a5a836cf7269a10b8f7278a10727893a73e42895 Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 1/5] refactor(elixir): read a def head through its guard from one helper `def name(args) when guard` does not parse as a call with a guard beside it. tree-sitter-elixir builds one `when` binary_operator for the whole head, and the `name(args)` call that names the function is that operator's left operand. Four walks read that same node, the def's first argument, and each asks a different question of it: the defs walk names the function from it; the unified walk opens the function's call scope from it; the usages walk bounds the head's bindings with it; the calls walk tells a definition head apart from an invocation by it. A private copy in one of them makes the rest disagree about what a guarded clause is. The disagreement is silent, and it mints an edge: a head one walk treats as a declaration and another does not is recorded as a call to the very function being defined. Two questions, so two exported entry points: cbm_elixir_def_head_unwrap_guard(first_arg) returns the head UNDER every guard, or the node unchanged when there is none. The three walks that need a name or a span take this. cbm_elixir_def_head_is(signature, node) is true for that head OR for any `when` operator above it. The calls walk takes this, because a guarded head reaches it by two routes and it sees only one node per visit: `def f(x) when g` arrives as the inner `f(x)` call, and `def f when g` has no inner call at all, so the `when` operator itself arrives. Recognising one route and not the other leaves the phantom in place for the other. Both test the operator TOKEN rather than the node kind. `def a + b` is a binary_operator head too, and peeling it would name the function after its own left parameter. This commit changes no extraction behaviour: nothing calls either helper yet. The walks are separate commits, each with its own measurement. Measured while writing the test, which corrects a claim worth not repeating: tree-sitter-elixir nests multiple guards RIGHT-associatively. `def f(x) when is_atom(x) when is_binary(x)` parses as `when(f(x), when(is_atom(x), is_binary(x)))`, so the head is the outer operator's left operand and one peel reaches it. The peel still runs to a fixed point (one extra token compare), so the helper's contract is "the head under every guard" even where one peel would do. In tests/test_extraction.c, the test elixir_def_head_is_covers_the_head_and_every_guard_above_it drives the helpers on the AST directly rather than through cbm_extract_file. It has to: the two routes into the calls walk are the pair a single-walk test cannot separate, because each walk only ever sees the node its own traversal reaches. It pins the unguarded case, both guarded shapes, the double guard, and that `def a + b` is returned unchanged with its left operand answering false. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/helpers.c | 87 +++++++++++++++++++++ internal/cbm/helpers.h | 20 +++++ tests/test_extraction.c | 164 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 50c75bdf2..6ddd8ceaf 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1140,6 +1140,93 @@ const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, c return scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name; } +/* ── Elixir def-head guards ─────────────────────────────────── */ + +/* The guard sits on the head, not beside it: `def name(args) when guard` parses + * as a single `when` binary_operator whose left operand is `name(args)`. The + * operator field is an exact anonymous token, so the check is the token type + * rather than the node kind — `def a + b` is a binary_operator head too, and + * unwrapping it would name the function after its left parameter. */ +bool cbm_elixir_is_when_guard(TSNode node) { + if (ts_node_is_null(node) || strcmp(ts_node_type(node), "binary_operator") != 0) { + return false; + } + TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator")); + return !ts_node_is_null(op) && strcmp(ts_node_type(op), "when") == 0; +} + +/* One `when` level off a def's first argument, or the node unchanged. + * + * There is deliberately no named-child fallback for a missing `left`. An infix + * operator cannot reduce without a left operand, so tree-sitter-elixir never + * builds a `when` binary_operator that lacks one: measured over every guarded + * shape in the suite plus a corpus of truncated heads (`def when true`, + * `def f(x) when`, `def f(x) when when is_x(x)`, `def when() when when`), + * `left` was present on all 66 unwraps, ERROR-recovered parses included, and + * every one had the left operand as named child 0. Where the left operand was + * genuinely absent the grammar produced no `when` operator at all -- it read + * `when` as the head identifier -- so the unwrap is never entered. A + * named-child fallback would therefore be dead on a well-formed tree and, on + * the hypothetical malformed one, would hand back named child 0 without + * knowing it is the left operand rather than the guard expression: naming the + * function after its own guard. Returning the node unchanged degrades to the + * pre-guard behaviour instead, which is the safe direction. */ +static TSNode elixir_unwrap_one_guard(TSNode first_arg) { + if (!cbm_elixir_is_when_guard(first_arg)) { + return first_arg; + } + TSNode lhs = ts_node_child_by_field_name(first_arg, TS_FIELD("left")); + return ts_node_is_null(lhs) ? first_arg : lhs; +} + +/* The head under every guard a def's first argument carries, or first_arg + * unchanged when it carries none. Elixir admits more than one guard on a + * clause -- `def f(x) when is_atom(x) when is_binary(x)` -- and + * tree-sitter-elixir nests those RIGHT-associatively, measured on that exact + * source: `when(f(x), when(is_atom(x), is_binary(x)))`. The declared head is + * therefore the outer operator's left operand and one peel reaches it. The + * peel still runs to a fixed point: the extra iteration is one token compare, + * and it is what makes the helper's contract "the head under EVERY guard" + * rather than "the head under the first one", which is the property the three + * walks rely on. + * + * Left wrapped, the defs walk drops the clause, the unified walk opens no + * function scope (so every call in a guarded body sources to the FILE node), + * the calls walk stops recognising the head and emits it as an invocation of + * the very function being defined, and the usages walk swallows every + * identifier in the guard as part of the binding. Four walks read this same + * argument, which is why the unwrap lives here rather than in any one of + * them. */ +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg) { + for (;;) { + TSNode next = elixir_unwrap_one_guard(first_arg); + if (ts_node_eq(next, first_arg)) { + return first_arg; + } + first_arg = next; + } +} + +/* True when `node` is the head a def declares, or any of the `when` guard + * operators wrapping it. The calls walk needs the whole chain, not just its + * ends: each member reaches that walk by its own route -- `def f(x) when g` + * arrives as the inner `f(x)` call, `def f when g` has no inner call at all and + * the operator itself falls through to extract_callee_name's first-identifier + * last resort -- and a member it fails to recognise as a declaration is emitted + * as an invocation of the function being defined. */ +bool cbm_elixir_def_head_is(TSNode signature, TSNode node) { + for (;;) { + if (ts_node_eq(signature, node)) { + return true; + } + TSNode next = elixir_unwrap_one_guard(signature); + if (ts_node_eq(next, signature)) { + return false; + } + signature = next; + } +} + static const char *func_node_name(CBMArena *a, TSNode func_node, const char *source, CBMLanguage lang) { // Wolfram: set_delayed_top/set_top/set_delayed/set — LHS is apply(user_symbol("f"), ...) diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 905de04a8..c18f27429 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -103,6 +103,26 @@ const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char // attrpath and an enclosing attrset compose into one qualified name. const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name); +// ── Elixir def-head guards ── +// `def f(x) when g` parses its WHOLE head as a `when` binary_operator, so the +// `f(x)` call naming the function is the operator's left operand, and more than +// one guard (`when a when b`) nests those operators right-associatively, so the +// head stays the outermost operator's left operand in either shape. Four +// walks read that same first argument — the defs walk names the function from +// it, the unified walk opens the function's call scope from it, the calls walk +// tells a head apart from an invocation by it, and the usages walk excludes the +// head's identifiers from the usage set by it — so a private copy in one of +// them makes the rest disagree about what a guarded clause is. +// cbm_elixir_is_when_guard answers "is this node a guarded head"; the unwrap +// returns the head under every guard, or the node unchanged when it carries +// none; cbm_elixir_def_head_is answers whether a node is that head or one of +// the guard operators above it, which is the question the calls walk asks. +// All three test the operator token, so an operator definition (`def a + b`) is +// left alone rather than mis-read as a guard. +bool cbm_elixir_is_when_guard(TSNode node); +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg); +bool cbm_elixir_def_head_is(TSNode signature, TSNode node); + // Resolve a function/method definition node's NAME node across all ~130 grammars // (generic `name` field, arrow→declarator, C/C++ declarator chain, plus the many // per-language quirks: Fortran subroutine, SCSS mixin, SQL create_function, R, diff --git a/tests/test_extraction.c b/tests/test_extraction.c index afbdee26f..46f51feeb 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -16,6 +16,8 @@ #include "result_spill.h" #include "pipeline/pass_lsp_cross.h" #include "iris_export_xml.h" +#include "helpers.h" +#include "lang_specs.h" /* ── Helpers ───────────────────────────────────────────────────── */ @@ -1246,6 +1248,167 @@ TEST(elixir_function) { PASS(); } +/* ── Elixir def-head guard helpers (internal/cbm/helpers.c) ───────── + * + * `def name(args) when guard` parses its WHOLE head as one `when` + * binary_operator whose left operand is the `name(args)` call, and Elixir + * admits more than one guard on a clause, which tree-sitter-elixir nests + * right-associatively. Four walks read that same first argument and each asks a + * different question of it: the defs and call-scope walks want the head UNDER + * every guard, the usages walk wants it so that a parameter READ by the guard + * stays outside the binding, and the calls walk wants to know whether the node + * it is visiting IS that head or one of the guard operators above it. + * + * That last question is why cbm_elixir_def_head_is exists beside the unwrap, + * and it is exercised here on the AST rather than through cbm_extract_file + * because the two routes it has to answer for are indistinguishable from + * outside: `def f(x) when g` reaches the calls walk as the inner `f(x)` call, + * which the unwrap reaches, while `def f when g` has no inner call at all and + * reaches it as the `when` operator itself, which the unwrap does not. A test + * that only drives one walk sees whichever of the two that walk happens to hit. + * + * This file's helpers change no extraction behaviour on their own; the walks + * that consume them are separate changes. */ + +enum { ELIXIR_HEAD_PROBE_MAX = 16, ELIXIR_HEAD_STACK_MAX = 256 }; + +static bool head_probe_text_is(const char *source, TSNode node, const char *want) { + if (ts_node_is_null(node)) { + return false; + } + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + size_t len = strlen(want); + return end >= start && (size_t)(end - start) == len && memcmp(source + start, want, len) == 0; +} + +/* The first argument of every `def`/`defp` call under `root`, in source order. + * That argument is what all four walks read, so it is what the helpers take. */ +typedef struct { + TSNode signature[ELIXIR_HEAD_PROBE_MAX]; + int count; +} elixir_head_probe_t; + +static void collect_elixir_def_signatures(TSNode root, const char *source, + elixir_head_probe_t *out) { + TSNode stack[ELIXIR_HEAD_STACK_MAX]; + int top = 0; + stack[top++] = root; + while (top > 0) { + TSNode cur = stack[--top]; + uint32_t cc = ts_node_child_count(cur); + if (cc > 1 && strcmp(ts_node_type(cur), "call") == 0 && + (head_probe_text_is(source, ts_node_child(cur, 0), "def") || + head_probe_text_is(source, ts_node_child(cur, 0), "defp")) && + out->count < ELIXIR_HEAD_PROBE_MAX) { + TSNode args = ts_node_child(cur, 1); + if (!ts_node_is_null(args) && ts_node_child_count(args) > 0) { + out->signature[out->count++] = ts_node_child(args, 0); + } + } + for (int i = (int)cc - 1; i >= 0 && top < ELIXIR_HEAD_STACK_MAX; i--) { + stack[top++] = ts_node_child(cur, (uint32_t)i); + } + } +} + +TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it) { + const char *source = "defmodule Heads do\n" + " def plain(x), do: x\n" + " def guarded(x) when is_binary(x), do: x\n" + " def bare when true, do: :ok\n" + " def twice(x) when is_atom(x) when is_binary(x), do: x\n" + " def a + b, do: {a, b}\n" + "end\n"; + const TSLanguage *language = cbm_ts_language(CBM_LANG_ELIXIR); + ASSERT_NOT_NULL(language); + TSParser *parser = ts_parser_new(); + ASSERT_NOT_NULL(parser); + ASSERT_TRUE(ts_parser_set_language(parser, language)); + TSTree *tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)strlen(source)); + ASSERT_NOT_NULL(tree); + + elixir_head_probe_t probe; + probe.count = 0; + collect_elixir_def_signatures(ts_tree_root_node(tree), source, &probe); + ASSERT_EQ(5, probe.count); + + /* Unguarded: nothing to peel, and the head is the whole first argument. + * The name node inside it is NOT the head -- the calls walk compares the + * node it is visiting, and the `plain` identifier is one of those. */ + TSNode plain = probe.signature[0]; + ASSERT_FALSE(cbm_elixir_is_when_guard(plain)); + ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(plain), plain)); + ASSERT_TRUE(cbm_elixir_def_head_is(plain, plain)); + ASSERT_FALSE(cbm_elixir_def_head_is(plain, ts_node_child(plain, 0))); + + /* One guard: the first argument is the operator, the head is its left + * operand, and BOTH answer true -- the operator is the node the paren-less + * route hands the calls walk, the inner call is the node the peel route + * hands it. The guard expression answers false, or the walk would suppress + * a real call written inside a guard. */ + TSNode guarded = probe.signature[1]; + ASSERT_TRUE(cbm_elixir_is_when_guard(guarded)); + TSNode guarded_head = cbm_elixir_def_head_unwrap_guard(guarded); + ASSERT_FALSE(ts_node_eq(guarded_head, guarded)); + ASSERT_STR_EQ("call", ts_node_type(guarded_head)); + ASSERT_TRUE(head_probe_text_is(source, guarded_head, "guarded(x)")); + ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded)); + ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded_head)); + TSNode guard_expr = ts_node_child_by_field_name(guarded, TS_FIELD("right")); + ASSERT_FALSE(ts_node_is_null(guard_expr)); + ASSERT_TRUE(head_probe_text_is(source, guard_expr, "is_binary(x)")); + ASSERT_FALSE(cbm_elixir_def_head_is(guarded, guard_expr)); + + /* Paren-less: the head under the guard is a bare identifier, so there is no + * inner call for the peel route to produce. This is the shape that makes + * cbm_elixir_def_head_is necessary: the only node the calls walk ever sees + * for this clause is the operator. */ + TSNode bare = probe.signature[2]; + ASSERT_TRUE(cbm_elixir_is_when_guard(bare)); + TSNode bare_head = cbm_elixir_def_head_unwrap_guard(bare); + ASSERT_STR_EQ("identifier", ts_node_type(bare_head)); + ASSERT_TRUE(head_probe_text_is(source, bare_head, "bare")); + ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare)); + ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare_head)); + + /* Two guards on one clause. tree-sitter-elixir nests them + * RIGHT-associatively -- `when(twice(x), when(is_atom(x), is_binary(x)))`, + * which is what this asserts -- so the head is still the outer operator's + * left operand and the peel reaches it in one step. The repeated peel is + * therefore a fixed point rather than a chain walk on this grammar; the + * nodes that must answer true are the operator and the head. */ + TSNode twice = probe.signature[3]; + ASSERT_TRUE(cbm_elixir_is_when_guard(twice)); + TSNode second_guard = ts_node_child_by_field_name(twice, TS_FIELD("right")); + ASSERT_TRUE(cbm_elixir_is_when_guard(second_guard)); + TSNode twice_head = cbm_elixir_def_head_unwrap_guard(twice); + ASSERT_TRUE(head_probe_text_is(source, twice_head, "twice(x)")); + ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice)); + ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice_head)); + /* The second guard is a `when` operator too, and it is NOT on the head + * chain: a walk that treated any `when` above the head as a declaration + * would suppress the calls written inside it. */ + ASSERT_FALSE(cbm_elixir_def_head_is(twice, second_guard)); + + /* An operator definition's head is a binary_operator too, and unwrapping it + * would name the function after its own left parameter. Only the `when` + * token admits the peel, so `def a + b` is returned unchanged and its left + * operand is not a head. */ + TSNode op_def = probe.signature[4]; + ASSERT_STR_EQ("binary_operator", ts_node_type(op_def)); + ASSERT_FALSE(cbm_elixir_is_when_guard(op_def)); + ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(op_def), op_def)); + ASSERT_TRUE(cbm_elixir_def_head_is(op_def, op_def)); + TSNode op_lhs = ts_node_child_by_field_name(op_def, TS_FIELD("left")); + ASSERT_TRUE(head_probe_text_is(source, op_lhs, "a")); + ASSERT_FALSE(cbm_elixir_def_head_is(op_def, op_lhs)); + + ts_tree_delete(tree); + ts_parser_delete(parser); + PASS(); +} + /* tree-sitter-elixir gives a call's arguments node no field name, so the * generic `arguments` field lookup returns null and first_string_arg was never * populated for any Elixir call — Phoenix route paths, service URLs and config @@ -8487,6 +8650,7 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); + RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it); RUN_TEST(elixir_call_string_argument); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); From 630f851526dfdd3ce5672e267598c0eeb5a2155a Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 2/5] fix(elixir): extract a def head wrapped in a `when` guard extract_elixir_func_def reads the def's first argument directly and accepts only a `call` or an `identifier` there. A guard makes that argument a binary_operator, so the extractor found no name and returned. Every guarded clause was dropped, and a function whose clauses ALL carry guards never reached the graph at all. The loss is silent: a missing definition is not an error anywhere in the pipeline. Guards are ordinary Elixir. Measured by indexing one 970-file Elixir lib tree with the binary built from this commit's parent and with this one, and diffing the `nodes` and `edges` rows out of the two SQLite stores: Function nodes 20,882 -> 21,941 new qualified names 1,059 (functions with no node at all before) lost qualified names 0 CALLS edges 55,851 -> 57,129 USAGE edges 27,418 -> 28,223 Every one of those 1,059 is a function whose every clause carries a guard: a clause without one already minted the node they collided on. An agent asking "who calls this" about any of them got an empty answer that reads exactly like dead code. The unwrap goes in the shared helper. The calls, unified and usages walks read the same node, and a private copy in one of them makes the rest disagree about what a guarded clause is. Two regressions come with the new node. Neither is minted here: both are pre-existing phantoms that had nowhere to land while guarded clauses were dropped, and now have a node. 1. A phantom CALLS edge onto the function being defined. The calls walk decides a def head is a declaration by comparing the visited node against the def's first argument, and a guard makes that argument the `when` operator, so the inner `name(args)` call no longer compares equal. cbm.c flags self-recursion by line containment, so the phantom lands inside the function's own span: self_recursive Function nodes 129 -> 1,342 ...with no self-call form in span 24 -> 1,241 (18.6% -> 92.5%) (measured by reading each flagged node's own source span and looking for any non-declaration line that names it as a call, a capture or a pipe target.) 2. A phantom WRITES edge onto it, from a paren-less guarded head. `def f when g` has no inner call at all, so the `when` operator reaches the read/write extractor and its first identifier is recorded as an assignment target. Reproduced on `def bare_guarded when true, do: :ok`: one CBMReadWrite row, var_name "bare_guarded", is_write true, scoped to the file's Module node (the graph asserting that a module mutates a function). Corpus WRITES 11,220 -> 11,302 (+82). Both become observable with this commit, and the guarded call-scope commit later in this stack closes them, by different mechanisms. The CALLS phantom goes because that commit admits the `when` operator to the definition-container check, so a paren-less head is recognised as a declaration. The WRITES phantom goes because that commit opens a function scope for the clause: the read/write row is still recorded, but its enclosing_func_qn becomes the guarded clause itself rather than the enclosing module, so resolve_rw_edges finds src->id == tgt->id and drops the self-edge. They are stated here because a stack that lands partially must not hide them. tests/test_extraction.c: extract_elixir_guarded_def_head covers every guarded shape (one guard, two guards, a `defp`, a paren-less `def bare when true`) and both operator-definition forms that must NOT be unwrapped (`def a + b`, `def c - d when is_integer(c)`, neither of which is extracted, as before). It fails on the current tree for each of the guarded names. Depends on the shared def-head helper commit. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/extract_defs.c | 10 ++++++++ tests/test_extraction.c | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 0f376b960..cdd6b1c7f 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5435,6 +5435,16 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char return; } + // `def name(args) when guard` parses the whole head as a `when` + // binary_operator, so the name lives on its left operand rather than + // directly under the call. Without unwrapping it, every guarded clause is + // dropped, and a function whose clauses ALL carry guards never appears in + // the graph at all -- silently, since a missing definition is not an error. + // The unwrap lives in helpers.c because it peels only `when`: an operator + // definition (`def a + b`) is a binary_operator head too, and unwrapping + // that one would name the function after its own left parameter. + first_arg = cbm_elixir_def_head_unwrap_guard(first_arg); + const char *fk = ts_node_type(first_arg); char *name = NULL; if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 46f51feeb..f8b61d53c 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1409,6 +1409,54 @@ TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it) { PASS(); } +/* `def name(args) when guard` parses its whole head as a `when` + * binary_operator, so the name sits on the operator's left operand rather than + * directly under the call. The def extractor accepted only `call` and + * `identifier` there, so every guarded clause was dropped -- and a function + * whose clauses ALL carry guards never reached the graph at all, silently. + * Guards are ordinary Elixir: on one real 2.9k-function codebase this hid 216 + * of 270 all-guarded functions, and every call edge touching them. */ +TEST(extract_elixir_guarded_def_head) { + CBMFileResult *r = extract("defmodule Guarded do\n" + " def plain(x), do: x\n" + " def guarded(x) when is_binary(x), do: x\n" + " def guarded(x) when is_list(x), do: x\n" + " def multi(x, y) when is_binary(x) and is_integer(y), do: {x, y}\n" + " defp priv_guarded(x) when is_atom(x), do: x\n" + " def bare when true, do: :ok\n" + " def a + b, do: {a, b}\n" + " def c - d when is_integer(c), do: {c, d}\n" + " def multi_guard(x) when is_atom(x) when is_binary(x), do: x\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guarded.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "plain")); + ASSERT(has_def(r, "Function", "guarded")); + ASSERT(has_def(r, "Function", "multi")); + ASSERT(has_def(r, "Function", "priv_guarded")); + ASSERT(has_def(r, "Function", "bare")); + + /* Elixir admits more than one guard on a clause. tree-sitter-elixir nests + * those `when` operators right-associatively, so the declared head stays + * the outermost operator's left operand -- one node either way, and the + * peel must not stop short of it or return the guard. */ + ASSERT(has_def(r, "Function", "multi_guard")); + ASSERT_EQ(count_defs_named(r, "Function", "multi_guard"), 1); + + /* An operator definition is a binary_operator head too, but it is not a + * guard: unwrapping it names the function after its own LEFT PARAMETER. + * `def a + b` must not mint a function called `a`, nor `def c - d when ...` + * one called `c` -- the guard's left operand is itself the `c - d` + * operator, which names no function either. Neither operator form is + * extracted at all, which is what this grammar did before guards were + * handled; only the `when` token admits the unwrap. */ + ASSERT_EQ(count_defs_named(r, "Function", "a"), 0); + ASSERT_EQ(count_defs_named(r, "Function", "c"), 0); + cbm_free_result(r); + PASS(); +} + /* tree-sitter-elixir gives a call's arguments node no field name, so the * generic `arguments` field lookup returns null and first_string_arg was never * populated for any Elixir call — Phoenix route paths, service URLs and config @@ -8651,6 +8699,7 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it); + RUN_TEST(extract_elixir_guarded_def_head); RUN_TEST(elixir_call_string_argument); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); From 77d4beb7a8ad9a6b8486ee9196731dd46de69e9e Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 3/5] fix(elixir): span a multi-clause function's node over all of its clauses Every clause of an Elixir function is its own `def` call, and an Elixir QN carries neither module nor arity, so all clauses compute one qualified name and collide on a single graph node. cbm_gbuf_upsert_node breaks a same-QN collision by keeping the largest start_line, so the survivor was whichever clause was written last, and get_code_snippet returned that clause as the whole function: def admin?(%__MODULE__{role: :admin}), do: true def admin?(%__MODULE__{}), do: false read back as `def admin?(%__MODULE__{}), do: false`, so an agent asking what the function does is told the predicate always returns false. One node per function is right, so the fix widens that node's span instead of splitting each clause onto its own node. Two bounds keep the widened span honest, and a clause folds only if it clears both: it must sit in the same module body as the previous one (an Elixir QN carries no module, so `Outer.run` and `Outer.Inner.run` compute the same name), and it must be adjacent to it (only the immediately preceding EXTRACTED definition is a candidate, so a def of another name ends the group). Measured by indexing one 970-file Elixir lib tree with the binary built from this commit's parent and with this one, and diffing the `nodes` and `edges` rows out of the two SQLite stores: 4,135 functions stop being reported as one line of source, and none starts, which is the get_code_snippet defect measured on this corpus. A widened span is only honest if nothing phantom lives inside it, which is why the head suppression in extract_calls.c lands with the fold rather than as a follow-up. A guarded clause head is a declaration of the name, never a call to it, and the calls walk did not agree: it suppressed a head only when the head node IS the def's first named argument, and `def f(x) when g` parses its whole head as a `when` binary_operator, so the inner `f(x)` was not that argument. It now asks cbm_elixir_def_head_is, the same helper the defs walk reads the head through. cbm.c flags self-recursion by line containment, so under the folded span that phantom would land inside the function's own node, and `recursive` is a queryable node property that seeds the cycle detection in pass_complexity. The widened span has a real cost. Reading each flagged node's own source span for any line that names it as a call, a capture or a pipe target, and counting a one-line clause's body after `, do:` as such a line: self_recursive Function nodes 129 -> 430 ...with no self-call form in span 6 -> 48 The lines responsible are the typespec heads a widened span now covers: `@spec f(t) :: u` puts the declared name in a `call` node and the unified walk reads it as code. That is a separate defect with its own fix later in this stack, and a fold cannot close it. Two things a reader of the graph will notice: Edge metadata moves on edges that SURVIVE. `edges` is UNIQUE on (source, target, type), so where the def-head phantom happened to be the dedup survivor for a pair, deleting it promotes another row and the surviving edge takes that row's `line`. Of the 119,696 edges that survive this commit, 1,054 do. The 2,436 that go are 2,104 CALLS and 332 USAGE, measured against this commit's parent, and none is added back. is_exported becomes the OR over the folded clauses. The arity-free QN puts a public entry point and its private accumulator on one node, and a name callable from outside must stay exported. Exactly 6 Function nodes change is_exported on this corpus, all false -> true and none the other way. Each is a `def` clause written above a `defp` clause of the same 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 correct. The typespec attributes put a declared name in a `call` node too and are deliberately NOT suppressed in the calls walk. A def head can be, because is_elixir_def_binding already treats a def's first argument as a binding, so the identifier handle_calls declines is declined by handle_usages too. Nothing treats a typespec subject as a binding, so declining one here would not remove its phantom, it would RELABEL it as a USAGE: a separate row under UNIQUE(source, target, type) that no longer collides with the real call it used to hide behind. extract_elixir_declaration_head_mints_no_reference_of_any_kind pins that as a relation rather than a count, because at extraction the relabel is invisible. Depends on the shared def-head helper and guarded def-heads commits. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/extract_calls.c | 55 +++++- internal/cbm/extract_defs.c | 130 +++++++++++++- tests/test_extraction.c | 338 +++++++++++++++++++++++++++++++++++ 3 files changed, 515 insertions(+), 8 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index e1de896b8..e74801b47 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -708,6 +708,59 @@ static bool elixir_call_head_in(TSNode call, const char *source, const char *con call_node_text_in(ts_node_child(call, 0), source, heads); } +/* A definition's head declares a name; it is never a call to it. The head under + * a guard is not the def's first argument -- `def f(x) when g` parses its whole + * head as a `when` binary_operator -- so the plain node comparison below stopped + * recognising it, and the inner `f(x)` was recorded as a call to `f`. Under the + * widened span that phantom lands inside the function's own node, and cbm.c + * decides self-recursion by line containment, so an ordinary two-clause guarded + * function reported itself recursive. `recursive` is a queryable node property + * and seeds the cycle detection in pass_complexity, so that is a load-bearing + * signal, not a cosmetic one. The suppression therefore travels with the fold + * rather than following it. + * + * The head comes from cbm_elixir_def_head_is (helpers.c) rather than a private + * peel here, because three files read this same node and all three have to + * agree on which node is the head, or a node one of them is treating as a + * definition name is recorded by another as a reference to that very name: + * - extract_defs.c, extract_elixir_func_def, unwraps it to NAME the + * definition. It and this file disagreeing mints the phantom above. + * - extract_usages.c, is_elixir_def_binding, treats the whole first argument + * as a binding occurrence rather than a reference, which is what keeps a + * head suppressed here from re-emerging as a USAGE edge. It is deliberately + * WIDER than the unwrap -- it covers the guard expression too -- and that + * width is load-bearing: a suppression in one extractor of the unified walk + * only REMOVES a phantom if the others also decline the node. + * - extract_unified.c, compute_elixir_func_qn, resolves a def's QN for call + * scope tracking and does NOT unwrap, so a guarded clause pushes no + * function scope and its call edges are sourced from the File rather than + * from the enclosing Function. Pre-existing, independent of the two above + * (it decides edge SOURCE, not edge existence), and fixed separately. + * + * Suppressing the head does not only delete edges; it moves metadata on edges + * that SURVIVE, and that is worth stating because a caller reading an edge's + * `line` will see a different number than before. `edges` is UNIQUE on + * (source, target, type), so where the def-head phantom happened to be the + * dedup survivor for a pair, deleting it promotes some other row for that same + * pair and the surviving edge takes that row's `line`. + * + * Measured by indexing one 970-file Elixir lib tree with the binary built from + * this commit's parent and with this one, and diffing the `edges` rows out of + * the two SQLite stores keyed on (source QN, target QN, type): 2,436 edges go + * and NONE is added -- 2,104 CALLS and 332 USAGE. Of the 119,696 edges that + * survive, 1,054 record a different `line`. + * + * The typespec attributes -- `@spec f(t) :: u` and the @callback / @type family + * -- put the declared name in a `call` node too, and are deliberately NOT + * suppressed here. A def head can be, because extract_usages.c already treats a + * def's first argument as a binding. Nothing there treats a typespec subject as + * one, so declining it in this walk would not remove its phantom, it would + * RELABEL it: handle_usages reaches the bare identifier handle_calls just + * declined and mints a USAGE onto the same function, which under + * UNIQUE(source, target, type) is a separate row that no longer collides with + * the real call it used to hide behind, and which pass_importance then counts. + * A typespec phantom has to be removed before any extractor sees it, by + * skipping the whole subtree in the unified walk. */ static bool elixir_call_is_definition_role(TSNode node, const char *source) { static const char *const structural_heads[] = {"def", "defp", "defmacro", "defmodule", NULL}; static const char *const function_heads[] = {"def", "defp", "defmacro", NULL}; @@ -725,7 +778,7 @@ static bool elixir_call_is_definition_role(TSNode node, const char *source) { TSNode signature = ts_node_named_child_count(arguments) > 0 ? ts_node_named_child(arguments, 0) : arguments; - return ts_node_eq(signature, node); + return cbm_elixir_def_head_is(signature, node); } return false; } diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index cdd6b1c7f..839e482de 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5422,8 +5422,110 @@ static TSNode elixir_call_args(TSNode node) { return args; } -// Handle Elixir def/defp/defmacro — extract function definition. -static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro) { +// 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 +// and collide on a single graph node. cbm_gbuf_upsert_node breaks a same-QN +// collision by keeping the LARGEST start_line, so the survivor was the last +// clause in the file and get_code_snippet returned that clause as the whole +// function: `def admin?(%__MODULE__{role: :admin}), do: true` / +// `def admin?(%__MODULE__{}), do: false` read back as a predicate that always +// returns false. One node per function is right, so widen its span instead. +// +// Two bounds keep the widened span honest, and a clause folds only if it clears +// both: +// - Same module body. `scope` is the do_block the previous clause was written +// in, so a nested `defmodule` cannot merge with its parent: the QN carries +// no module, so `Outer.run` and `Outer.Inner.run` compute the same name and +// are array-adjacent when Inner is declared just above Outer's own clause. +// - Same group. Only the immediately preceding EXTRACTED definition is a +// candidate, so a def of another name between two same-named defs ends the +// group. That is a bound this code imposes, not a guarantee Elixir gives: +// the compiler only WARNS ("clauses with the same name and arity should be +// grouped together"), it warns per name AND arity while this QN is +// arity-free, and non-contiguous clauses do compile. Split clauses +// therefore keep separate nodes, which is the pre-existing behaviour. +// What the group bound does NOT exclude is a non-definition construct +// between two clauses: `@doc` / `@spec` are unary_operators, `use` / +// `alias` / `describe` are ordinary calls, and a `defimpl` block's inner +// defs are not extracted -- none of them push a def, so anything of that +// kind written between two clauses of one function ends up inside the +// widened span, and that is not rare. Measured by indexing one 970-file +// Elixir lib tree with the binary built from this commit's parent and with +// this one and diffing the `nodes` rows out of the two SQLite stores: 6,996 +// Function nodes widen their span, none is added, none is lost and none +// narrows. Reading each widened span for a module-body construct written at +// the module's own two-space indent, which no clause body reaches, 169 of +// the 6,996 cover such a line. Counting each node once per kind, 123 cover +// a typespec attribute (@spec/@type/@typep/@opaque/@callback/ +// @macrocallback), 100 an @doc or @typedoc, 46 a bare comment line, 14 an +// @impl, and none a `use`, `alias`, `import`, `require` or a module-level +// `quote`. Swallowed typespecs are the largest attribute kind, not an +// absent one. +// +// The widened span also admits a phantom call that a narrower span kept out, +// which is why the head suppression in extract_calls.c travels with this fold +// rather than after it. cbm.c flags self-recursion by finding the innermost +// Function whose [start_line, end_line] contains a recorded call whose short +// name matches the function's own, so any pre-existing phantom between the +// first and the last clause becomes a self-edge as soon as the span covers it. +// That cost is real and is measured rather than asserted away. Same corpus, +// reading each flagged node's own source span for any line that names it as a +// call, a capture or a pipe target, counting a one-line clause's body after +// `, do:` as such a line: self_recursive Function nodes go 129 -> 430, and the +// ones carrying no self-call form anywhere in their span go 6 -> 48. The lines +// responsible are the typespec heads the widened span now covers -- `@spec +// f(t) :: u` puts the declared name in a `call` node, and the unified walk +// reads it as code -- a separate defect with a separate fix, not something this +// fold can close. What the fold and the head suppression together remove is +// 2,436 edges, none of them added back: see elixir_call_is_definition_role in +// extract_calls.c. +// +// A clause whose macro differs (`def` foo/1 beside `defp` foo/2) still folds, +// because the arity-free QN already puts both on one node; is_exported is then +// the OR over the folded clauses, so a name any clause exports stays exported. +// That OR changes a recorded flag, which the node diff confirms and which is +// worth stating rather than leaving to be discovered: on the same corpus +// exactly 6 Function nodes change is_exported, all false -> true and none the +// other way. Each is a `def` clause written above a `defp` clause of the same +// 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. +// 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) { + if (!qn || ctx->result->defs.count <= 0 || ts_node_is_null(scope)) { + return false; + } + TSNode here = ts_node_parent(node); + if (ts_node_is_null(here) || !ts_node_eq(here, scope)) { + return false; + } + CBMDefinition *prev = &ctx->result->defs.items[ctx->result->defs.count - 1]; + if (!prev->label || strcmp(prev->label, "Function") != 0 || !prev->qualified_name || + strcmp(prev->qualified_name, qn) != 0) { + return false; + } + /* Only end_line moves. prev->start_line is already the minimum, so this + * takes no start_line argument: a clause folds only into the def pushed + * for the immediately preceding extracted clause of the same do_block, and + * extract_elixir_call pushes a do_block's children onto its stack in + * reverse index order so they pop in source order. A `start_line < + * prev->start_line` guard here would be unreachable; if that traversal + * ever stops being source-ordered, this is the line that has to change + * with it. */ + if (end_line > prev->end_line) { + prev->end_line = end_line; + } + prev->is_exported = prev->is_exported || is_exported; + 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. +static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro, + TSNode *scope) { CBMArena *a = ctx->arena; TSNode args = elixir_call_args(node); if (ts_node_is_null(args)) { @@ -5456,15 +5558,25 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char return; } + const char *qn = cbm_fqn_compute(a, ctx->project, ctx->rel_path, name); + 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); + *scope = ts_node_parent(node); + if (folded) { + return; + } + CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; - def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, name); + def.qualified_name = qn; def.label = "Function"; def.file_path = ctx->rel_path; - 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.start_line = start_line; + def.end_line = end_line; + def.is_exported = is_exported; cbm_defs_push(&ctx->result->defs, a, def); } @@ -5501,6 +5613,10 @@ static TSNode emit_elixir_module_class(CBMExtractCtx *ctx, TSNode cur) { // without recursion between extract_elixir_call ↔ extract_elixir_module_def. static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { (void)spec; + /* Module body the last extracted clause was written in; see + * fold_elixir_clause. Null until the first def, so nothing folds into a + * def left over from a previous top-level call node. */ + TSNode def_scope = {0}; TSNodeStack stack; ts_nstack_init(&stack, ctx, CBM_SZ_64); ts_nstack_push(&stack, node); @@ -5523,7 +5639,7 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp if (strcmp(macro, "def") == 0 || strcmp(macro, "defp") == 0 || strcmp(macro, "defmacro") == 0) { - extract_elixir_func_def(ctx, cur, macro); + extract_elixir_func_def(ctx, cur, macro, &def_scope); } else if (strcmp(macro, "defmodule") == 0) { TSNode do_block = emit_elixir_module_class(ctx, cur); if (!ts_node_is_null(do_block)) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index f8b61d53c..887457cde 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1457,6 +1457,338 @@ TEST(extract_elixir_guarded_def_head) { PASS(); } +/* Every clause of an Elixir function is its own `def` call, and an Elixir QN + * carries neither module nor arity, so all clauses compute one qualified name + * and collide on a single graph node. cbm_gbuf_upsert_node ranks a same-QN + * collision by LARGEST start_line, so the survivor was the last clause in the + * file and get_code_snippet returned that clause as the whole function: a + * two-clause `admin?` read back as `def admin?(%__MODULE__{}), do: false` -- + * the graph asserting a predicate that always returns false. The node has to + * span its own clauses, and ONLY its own: the fold is bounded to the + * immediately preceding definition and to the enclosing module body, so a def + * of another name ends the group and two same-named functions in two modules + * stay two nodes. */ +TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module) { + CBMFileResult *r = extract("defmodule Roles do\n" /* 1 */ + " def admin?(%{role: :admin}), do: true\n" /* 2 */ + " def admin?(%{}), do: false\n" /* 3 */ + "\n" /* 4 */ + " def promote(user), do: user\n" /* 5 */ + "\n" /* 6 */ + " def admin?(_other), do: false\n" /* 7 */ + "end\n" /* 8 */ + "\n" /* 9 */ + "defmodule Guests do\n" /* 10 */ + " def admin?(:root), do: true\n" /* 11 */ + " def admin?(_any), do: false\n" /* 12 */ + "end\n", /* 13 */ + CBM_LANG_ELIXIR, "t", "roles.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* The two adjacent Roles clauses fold; the clause past `promote` is its own + * group; the Guests pair is a DIFFERENT function and stays its own node. */ + ASSERT_EQ(3, count_defs_named(r, "Function", "admin?")); + ASSERT_EQ(1, count_defs_named(r, "Function", "promote")); + + const CBMDefinition *admin[3] = {NULL, NULL, NULL}; + const CBMDefinition *promote = NULL; + int seen = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") != 0) { + continue; + } + if (strcmp(r->defs.items[i].name, "admin?") == 0 && seen < 3) { + admin[seen++] = &r->defs.items[i]; + } else if (strcmp(r->defs.items[i].name, "promote") == 0) { + promote = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(admin[0]); + ASSERT_NOT_NULL(admin[1]); + ASSERT_NOT_NULL(admin[2]); + ASSERT_NOT_NULL(promote); + + ASSERT_EQ(2, (int)admin[0]->start_line); + ASSERT_EQ(3, (int)admin[0]->end_line); + /* separated from lines 2-3 by `promote`, so it keeps its own span */ + ASSERT_EQ(7, (int)admin[1]->start_line); + ASSERT_EQ(7, (int)admin[1]->end_line); + /* Guests.admin? never reaches back over the module boundary */ + ASSERT_EQ(11, (int)admin[2]->start_line); + ASSERT_EQ(12, (int)admin[2]->end_line); + ASSERT_EQ(5, (int)promote->start_line); + ASSERT_EQ(5, (int)promote->end_line); + cbm_free_result(r); + PASS(); +} + +/* A nested module is the one place where the "def of another name ends the + * group" rule is not enough: an Elixir QN carries no module, so `Outer.run` and + * `Outer.Inner.run` compute the SAME qualified name, and when Inner sits + * directly above Outer's own clause the two are adjacent in the extracted defs. + * The fold is bounded to the module body as well, so they stay two nodes. */ +TEST(extract_elixir_nested_module_clauses_do_not_fold) { + CBMFileResult *r = extract("defmodule Outer do\n" /* 1 */ + " defmodule Inner do\n" /* 2 */ + " def run(a), do: a\n" /* 3 */ + " end\n" /* 4 */ + "\n" /* 5 */ + " def run(b), do: b + 1\n" /* 6 */ + "end\n", /* 7 */ + CBM_LANG_ELIXIR, "t", "nested.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(2, count_defs_named(r, "Function", "run")); + + const CBMDefinition *runs[2] = {NULL, NULL}; + int seen = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") == 0 && + strcmp(r->defs.items[i].name, "run") == 0 && seen < 2) { + runs[seen++] = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(runs[0]); + ASSERT_NOT_NULL(runs[1]); + ASSERT_EQ(3, (int)runs[0]->start_line); + ASSERT_EQ(3, (int)runs[0]->end_line); + ASSERT_EQ(6, (int)runs[1]->start_line); + ASSERT_EQ(6, (int)runs[1]->end_line); + cbm_free_result(r); + PASS(); +} + +/* Folding clauses has to decide is_exported for the merged node. A `def` clause + * and a `defp` clause of the same name AND arity are clauses of one function -- + * a private guard clause or a nil short-circuit written above the public head + * is ordinary Elixir -- so is_exported is the OR over the folded clauses: the + * name is callable from outside if any clause exports it, and a wholly private + * function stays unexported. */ +TEST(extract_elixir_folded_clause_is_exported_is_the_or) { + CBMFileResult *r = extract("defmodule Acc do\n" /* 1 */ + " def build(l), do: build(l)\n" /* 2 */ + " defp build([]), do: []\n" /* 3 */ + "\n" /* 4 */ + " defp trim(nil), do: nil\n" /* 5 */ + " defp trim(s), do: s\n" /* 6 */ + "end\n", /* 7 */ + CBM_LANG_ELIXIR, "t", "acc.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_defs_named(r, "Function", "build")); + ASSERT_EQ(1, count_defs_named(r, "Function", "trim")); + + const CBMDefinition *build = NULL; + const CBMDefinition *trim = NULL; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") != 0) { + continue; + } + if (strcmp(r->defs.items[i].name, "build") == 0) { + build = &r->defs.items[i]; + } else if (strcmp(r->defs.items[i].name, "trim") == 0) { + trim = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(build); + ASSERT_NOT_NULL(trim); + ASSERT_EQ(2, (int)build->start_line); + ASSERT_EQ(3, (int)build->end_line); + ASSERT_TRUE(build->is_exported); + ASSERT_EQ(5, (int)trim->start_line); + ASSERT_EQ(6, (int)trim->end_line); + ASSERT_FALSE(trim->is_exported); + cbm_free_result(r); + + /* The other direction, and the only one the `prev->is_exported ||` disjunct + * decides. Above, the exporting clause is the FIRST one, so a plain + * `prev->is_exported = is_exported` assignment would also have to lose it + * on the later defp -- but a last-clause-wins assignment happens to be + * right whenever the def comes last. Here the defp comes first and the def + * second, so the surviving flag can only be true if the fold ORs rather + * than assigns. */ + CBMFileResult *o = extract("defmodule Ord do\n" /* 1 */ + " defp z(1), do: 1\n" /* 2 */ + " def z(2), do: 2\n" /* 3 */ + "end\n", /* 4 */ + CBM_LANG_ELIXIR, "t", "ord.ex"); + ASSERT_NOT_NULL(o); + ASSERT_FALSE(o->has_error); + ASSERT_EQ(1, count_defs_named(o, "Function", "z")); + const CBMDefinition *z = NULL; + for (int i = 0; i < o->defs.count; i++) { + if (strcmp(o->defs.items[i].label, "Function") == 0 && + strcmp(o->defs.items[i].name, "z") == 0) { + z = &o->defs.items[i]; + } + } + ASSERT_NOT_NULL(z); + ASSERT_EQ(2, (int)z->start_line); + ASSERT_EQ(3, (int)z->end_line); + ASSERT_TRUE(z->is_exported); + cbm_free_result(o); + PASS(); +} + +/* First definition with this name, for the Elixir clause-fold tests below. + * find_def() is declared much further down this file. */ +static const CBMDefinition *elixir_first_def(CBMFileResult *r, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0) { + return &r->defs.items[i]; + } + } + return NULL; +} + +/* A guarded clause head is a DECLARATION of the name, never a call to it, and + * the call extractor has to agree with the def extractor about which node the + * head is. It did not: it suppressed a head only when the head node IS the + * def's first named argument, but `def f(x) when g` parses its whole head as a + * `when` binary_operator, so the inner `f(x)` was not that argument and was + * recorded as a call to `f`. Under the folded span that phantom lands inside + * the function's own node, and self-recursion is decided by line containment, + * so an ordinary two-clause guarded function reported itself recursive. + * Recursion is a load-bearing signal: `recursive` is a queryable node property + * and seeds the cycle detection in pass_complexity. */ +TEST(extract_elixir_guarded_clause_head_is_not_a_self_call) { + CBMFileResult *r = extract("defmodule Guarded do\n" /* 1 */ + " def f(x) when is_integer(x), do: x\n" /* 2 */ + " def f(_x), do: 0\n" /* 3 */ + "\n" /* 4 */ + " def solo(x) when is_binary(x), do: x\n" /* 5 */ + "end\n", /* 6 */ + CBM_LANG_ELIXIR, "t", "guard_call.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* No call to either name exists anywhere in this file. */ + ASSERT_EQ(0, count_calls_named(r, "f")); + ASSERT_EQ(0, count_calls_named(r, "solo")); + /* The guard predicate IS a real call and stays one. */ + ASSERT_EQ(1, count_calls_named(r, "is_integer")); + ASSERT_EQ(1, count_calls_named(r, "is_binary")); + + ASSERT_EQ(1, count_defs_named(r, "Function", "f")); + const CBMDefinition *f = elixir_first_def(r, "f"); + ASSERT_NOT_NULL(f); + ASSERT_EQ(2, (int)f->start_line); + ASSERT_EQ(3, (int)f->end_line); + ASSERT_FALSE(f->is_recursive); + + /* A single guarded clause was wrong even before the span widened: the + * phantom landed on its own one-line node. */ + const CBMDefinition *solo = elixir_first_def(r, "solo"); + ASSERT_NOT_NULL(solo); + ASSERT_FALSE(solo->is_recursive); + cbm_free_result(r); + PASS(); +} + +/* Every reference of any kind this file records for `name`, across all four + * extractors the unified walk runs over one subtree: a call site, a value + * usage, a read/write occurrence, a type reference. Suppressing a node in ONE + * of them does not remove a phantom reference, it moves it -- handle_calls + * declining a node leaves state->callee_expr unset, and handle_usages then + * reaches the bare identifier and mints a USAGE instead. USAGE and CALLS are + * separate rows in the edge table, so the relabelled phantom also escapes the + * UNIQUE(source,target,type) dedup that had been collapsing it onto a real + * call, and pass_importance counts an inbound reference that did not exist + * before. An assertion naming only r->calls cannot see any of that. */ +static int elixir_any_ref_count(CBMFileResult *r, const char *name) { + int count = count_calls_named(r, name); + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, name) == 0) { + count++; + } + } + for (int i = 0; i < r->rw.count; i++) { + if (r->rw.items[i].var_name && strcmp(r->rw.items[i].var_name, name) == 0) { + count++; + } + } + for (int i = 0; i < r->type_refs.count; i++) { + if (r->type_refs.items[i].type_name && strcmp(r->type_refs.items[i].type_name, name) == 0) { + count++; + } + } + return count; +} + +/* The head suppression has to REMOVE the phantom, not relabel it. What makes + * that hold for a def head is not the calls walk alone: is_elixir_def_binding + * in extract_usages.c already treats everything inside a def's first argument + * as a binding occurrence rather than a reference, so the identifier + * handle_calls declines is declined by handle_usages too. That agreement is the + * invariant under test, and it is why the counts below are over every extractor + * rather than over r->calls. + * + * `spec_subject` pins the boundary of this patch, and pins it as a RELATION + * rather than a count, because the count cannot see the defect. A typespec + * subject is the same shape as a def head and is NOT suppressed here: nothing + * in extract_usages.c treats it as a binding, so declining it in handle_calls + * only relabels its phantom as a USAGE. At extraction that relabel is + * invisible -- one reference before, one after -- and it becomes a defect + * downstream, where CALLS and USAGE are separate rows under + * UNIQUE(source,target,type) and the relabelled phantom stops colliding with + * the real call it used to hide behind. So the assertion is that every + * reference recorded for a typespec subject is a CALL and none has become a + * usage, a read/write or a type reference. */ +TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind) { + CBMFileResult *r = extract("defmodule Heads do\n" /* 1 */ + " def guarded(x) when is_integer(x), do: x\n" /* 2 */ + " def guarded(_x), do: 0\n" /* 3 */ + "\n" /* 4 */ + " defp bare(x), do: x\n" /* 5 */ + "\n" /* 6 */ + " @spec spec_subject(integer) :: integer\n" /* 7 */ + " def spec_subject(n), do: n\n" /* 8 */ + "end\n", /* 9 */ + CBM_LANG_ELIXIR, "t", "heads.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* Nothing in this file references `guarded` or `bare`. Not as a call, not + * as a value, not as a read/write, not as a type. */ + ASSERT_EQ(0, elixir_any_ref_count(r, "guarded")); + ASSERT_EQ(0, elixir_any_ref_count(r, "bare")); + /* The guard predicate is a real call and survives as one. */ + ASSERT_EQ(1, count_calls_named(r, "is_integer")); + ASSERT_EQ(1, elixir_any_ref_count(r, "is_integer")); + /* Declared, not suppressed here; see the comment above. Every reference + * this file records for a typespec subject is a CALL -- none of them has + * been relabelled into a usage, a read/write or a type reference. */ + ASSERT_EQ(count_calls_named(r, "spec_subject"), + elixir_any_ref_count(r, "spec_subject")); + cbm_free_result(r); + PASS(); +} + +/* The other direction: suppressing declaration heads must not blind the + * detector to real recursion, and folding must not lose a self-call written in + * a clause that is no longer the surviving one. `walk` recurses from its + * guarded second clause; exactly one call to `walk` is recorded (the body one, + * not the two heads) and the folded node is recursive. */ +TEST(extract_elixir_real_recursion_survives_head_suppression) { + CBMFileResult *r = extract("defmodule Rec do\n" /* 1 */ + " def walk([]), do: []\n" /* 2 */ + " def walk([h | t]) when is_integer(h), do: [h | walk(t)]\n" /* 3 */ + "end\n", /* 4 */ + CBM_LANG_ELIXIR, "t", "rec.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_calls_named(r, "walk")); + ASSERT_EQ(1, count_defs_named(r, "Function", "walk")); + const CBMDefinition *walk = elixir_first_def(r, "walk"); + ASSERT_NOT_NULL(walk); + ASSERT_EQ(2, (int)walk->start_line); + ASSERT_EQ(3, (int)walk->end_line); + ASSERT_TRUE(walk->is_recursive); + cbm_free_result(r); + PASS(); +} + /* tree-sitter-elixir gives a call's arguments node no field name, so the * generic `arguments` field lookup returns null and first_string_arg was never * populated for any Elixir call — Phoenix route paths, service URLs and config @@ -8700,6 +9032,12 @@ SUITE(extraction) { RUN_TEST(elixir_function); RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it); RUN_TEST(extract_elixir_guarded_def_head); + RUN_TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module); + RUN_TEST(extract_elixir_nested_module_clauses_do_not_fold); + RUN_TEST(extract_elixir_folded_clause_is_exported_is_the_or); + RUN_TEST(extract_elixir_guarded_clause_head_is_not_a_self_call); + RUN_TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind); + RUN_TEST(extract_elixir_real_recursion_survives_head_suppression); RUN_TEST(elixir_call_string_argument); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); From 198e211c3001e33538f67a1cadb623cbd550bf08 Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 4/5] fix(elixir): open a function scope for a guarded clause body A guard wraps the whole head in a `when` binary_operator, and three walks that read a def's first argument were not expecting one. The three failures are independent, and none is an error at runtime: The unified walk resolves a def's QN to open the function's call scope, and compute_elixir_func_qn accepts only a `call` or an `identifier` there. Handed a binary_operator it returns NULL, no function scope is pushed, and every call in a guarded body is attributed to the File node. The function reports no outgoing edges at all, and each of its callees gains an in-edge from the file. So "what does this function call" answers empty for a guarded clause, and "who calls this" answers with a file. The calls walk tells a definition head apart from an invocation through cbm_elixir_def_head_is, which covers the head and the `when` operators above it. A paren-less guarded clause (`def bare_guarded when true, do: ...`) never reaches that check: call_node_is_definition_container only admitted a `call` node, and here the head is the operator itself. Elixir's call node types include binary_operator, so the operator reached extract_callee_name and, with no callee of its own, took that function's last resort, the first identifier child, minting a phantom CALLS edge onto the very function being defined. The usages walk asks whether an identifier sits inside the def's signature and treats everything that does as a binding site rather than a reference. A guard makes that signature the whole `when` operator, which spans the guard expression too, so a parameter read by the guard was classified as its own binding and emitted no USAGE row at all. A guard is an expression over parameters that `guarded(a, b)` has already bound, so every mention of one to its right is a read, exactly as the same mention in the body already is. Measured by indexing one 970-file Elixir lib tree with the binary built from the base, 5df8b044, and with this one, and diffing the `edges` rows out of the two SQLite stores: Counted as the cumulative effect of the first four commits against that base, because this commit's attribution change is only observable once guarded clauses have nodes to be attributed to: CALLS sourced from a Module node 11,061 -> 5,840 (-5,221) CALLS sourced from a Function node 42,659 -> 49,579 (+6,920) CALLS sourced from a File node 2,131 -> 1,269 (-862) Calls written inside a guarded body stop being attributed to the enclosing module and are attributed to the function that writes them. The File count falls rather than rises, because the guarded-def-heads commit earlier in this stack gives those functions a node to source from. Against this commit's own parent, 5,825 USAGE rows appear for parameters read inside a guard that had been classified as their own binding. Node counts are identical in the two stores: the change moves where an edge comes from and records reads that were dropped. This also closes the WRITES phantom disclosed in the guarded-def-heads commit, though the extraction-level row does not go away. `def bare_guarded when true` still records one CBMReadWrite with var_name "bare_guarded" and is_write true. Its scope changes: with a function scope now open, enclosing_func_qn is the guarded clause where it had been the enclosing module, so resolve_rw_edges finds src->id == tgt->id and drops the self-edge. Measured on that exact fixture: before, one row scoped to `t.store`; after, one row scoped to `t.store.bare_guarded`. Corpus WRITES at this commit is 12,098 against 11,220 on the base; the typespec commit at the end of this stack brings it to 11,207. The row is still wrong at extraction and would reappear as an edge the moment anything sourced it elsewhere; removing it means teaching the read/write extractor about `when`, which is a fourth walk and belongs in its own change. tests/test_extraction.c: two tests. extract_elixir_guarded_def_head_scope asserts call attribution by exact scope QN rather than by callee name, because a call the scope walk failed to place carries the FILE QN, and a bare count of the callee passes just as happily with every call homed on the file. The same test pins the file-node count at 0. extract_elixir_guarded_def_head_guard_usages asserts each name both within its scope and in total, so an unwrap that took the guard instead of the head (admitting the binding sites themselves) fails on the totals. Only a `when` operator is admitted to the definition-container check. The generic first-identifier last resort still overrides a deliberate NULL for every other Elixir binary_operator that extract_scripting_callee declines (`=`, `<-`, `->`, `\\`, `::`), so `def g(c \\ 2)` still emits a phantom `c` and `e = target(d)` a phantom `e`. That is pre-existing behaviour for those operators and is untouched here. Depends on the shared def-head helper and multi-clause span commits. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/extract_calls.c | 47 +++++++++--- internal/cbm/extract_unified.c | 5 ++ internal/cbm/extract_usages.c | 5 ++ tests/test_extraction.c | 132 +++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 12 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index e74801b47..c4891c8e0 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -725,17 +725,24 @@ static bool elixir_call_head_in(TSNode call, const char *source, const char *con * definition name is recorded by another as a reference to that very name: * - extract_defs.c, extract_elixir_func_def, unwraps it to NAME the * definition. It and this file disagreeing mints the phantom above. - * - extract_usages.c, is_elixir_def_binding, treats the whole first argument - * as a binding occurrence rather than a reference, which is what keeps a - * head suppressed here from re-emerging as a USAGE edge. It is deliberately - * WIDER than the unwrap -- it covers the guard expression too -- and that - * width is load-bearing: a suppression in one extractor of the unified walk - * only REMOVES a phantom if the others also decline the node. - * - extract_unified.c, compute_elixir_func_qn, resolves a def's QN for call - * scope tracking and does NOT unwrap, so a guarded clause pushes no - * function scope and its call edges are sourced from the File rather than - * from the enclosing Function. Pre-existing, independent of the two above - * (it decides edge SOURCE, not edge existence), and fixed separately. + * - extract_usages.c, is_elixir_def_binding, treats an identifier inside that + * head as a binding occurrence rather than a reference, which is what keeps + * a head suppressed here from re-emerging as a USAGE edge: a suppression in + * one extractor of the unified walk only REMOVES a phantom if the others + * also decline the node. The two files ask different questions of the same + * helper -- this one whether the node IS the head or a `when` wrapper above + * it, that one whether the node sits INSIDE the head -- so there is one + * definition of the head and they cannot drift apart. That containment + * stops at the head rather than covering the whole `when` operator, which + * is deliberate in the other direction: only the parameters being BOUND are + * excluded, and a parameter READ in the guard is a reference, exactly as + * the same read in the body is. + * - extract_unified.c, compute_elixir_func_qn, resolves a def's QN to open + * the function's call scope and reads the head through the same helper. + * Handed the `when` operator instead it returns NULL, no scope is pushed, + * and every call in a guarded body is sourced from the File rather than + * from the enclosing Function. That failure is independent of the two above + * because it decides edge SOURCE, not edge existence. * * Suppressing the head does not only delete edges; it moves metadata on edges * that SURVIVE, and that is worth stating because a caller reading an edge's @@ -800,7 +807,23 @@ static bool call_node_is_definition_container(CBMLanguage lang, TSNode node, con if (lang == CBM_LANG_AGDA && strcmp(kind, "expr") == 0) { return agda_expr_is_definition_role(node); } - return lang == CBM_LANG_ELIXIR && strcmp(kind, "call") == 0 && + /* A guarded head is a `when` binary_operator, not a `call`, and Elixir's + * call node types include binary_operator -- so the head reaches this walk + * and must be able to answer that it is a definition. A paren-less clause + * (`def f when g`) has no inner call at all, so the operator node itself + * reaches extract_callee_name and, with no callee of its own, takes that + * function's last resort -- the first identifier child -- minting a phantom + * CALLS edge onto the very function being defined. + * + * That last resort overrides a deliberate NULL for every Elixir + * binary_operator extract_scripting_callee declines (`=`, `<-`, `->`, `\\`, + * `::`, `when`), so `def g(c \\ 2)` still emits a phantom `c` and + * `e = target(d)` a phantom `e`. That is the pre-existing behaviour for + * those operators and is untouched here: only a `when` operator is + * admitted, so an operator definition's own head (`def a + b`) stays an + * ordinary node, as it was before guards were handled at all. */ + return lang == CBM_LANG_ELIXIR && + (strcmp(kind, "call") == 0 || cbm_elixir_is_when_guard(node)) && elixir_call_is_definition_role(node, source); } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 086f501a0..68e0fbdcc 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -602,6 +602,11 @@ static const char *compute_elixir_func_qn(CBMExtractCtx *ctx, TSNode node) { if (ts_node_is_null(first_arg)) { return NULL; } + /* A guard wraps the whole head in a `when` operator, so the head naming the + * function is its left operand. Left wrapped, this returns NULL and the def + * opens NO function scope: every call in a guarded body then sources to the + * FILE node and the function itself reports no outgoing edges. */ + first_arg = cbm_elixir_def_head_unwrap_guard(first_arg); const char *fk = ts_node_type(first_arg); char *name = NULL; if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) { diff --git a/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index f6b391315..57594c665 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -617,6 +617,11 @@ static bool is_elixir_def_binding(CBMExtractCtx *ctx, TSNode node) { TSNode signature = ts_node_named_child_count(arguments) > 0 ? ts_node_named_child(arguments, 0) : arguments; + /* A guard makes that signature the whole `when` operator, which spans the + * guard expression as well as the head. Unwrapped to the head, only the + * parameters being BOUND are excluded here; a parameter READ inside the + * guard stays a usage, as the same read in the body already is. */ + signature = cbm_elixir_def_head_unwrap_guard(signature); return node_contains(head, node) || node_contains(signature, node); } return false; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 887457cde..1adca8bce 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -91,6 +91,47 @@ static int count_defs_named(CBMFileResult *r, const char *label, const char *nam return count; } +/* Count calls to `callee` whose enclosing scope is exactly `scope_qn`. A call the + * scope walk failed to place carries the FILE or module QN here, so a bare count + * of the callee passes just as happily with every call homed on the file. */ +static int count_calls_from(CBMFileResult *r, const char *callee, const char *scope_qn) { + int count = 0; + for (int i = 0; i < r->calls.count; i++) { + if (r->calls.items[i].callee_name && r->calls.items[i].enclosing_func_qn && + strcmp(r->calls.items[i].callee_name, callee) == 0 && + strcmp(r->calls.items[i].enclosing_func_qn, scope_qn) == 0) { + count++; + } + } + return count; +} + +/* Count usages of `ref` attributed to exactly `scope_qn`, and to any scope. A + * def head is a BINDING, not a usage, so the pair separates two different + * mistakes: too few rows means a read was swallowed by the binding, too many + * means a binding site was counted as a read. */ +static int count_usages_from(CBMFileResult *r, const char *ref, const char *scope_qn) { + int count = 0; + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && r->usages.items[i].enclosing_func_qn && + strcmp(r->usages.items[i].ref_name, ref) == 0 && + strcmp(r->usages.items[i].enclosing_func_qn, scope_qn) == 0) { + count++; + } + } + return count; +} + +static int count_usages_named(CBMFileResult *r, const char *ref) { + int count = 0; + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, ref) == 0) { + count++; + } + } + return count; +} + static int count_calls_named(CBMFileResult *r, const char *callee) { int count = 0; for (int i = 0; i < r->calls.count; i++) { @@ -1457,6 +1498,95 @@ TEST(extract_elixir_guarded_def_head) { PASS(); } +/* A guard hides the head from the scope walk and from the calls walk. + * compute_elixir_func_qn accepts only a `call` or an `identifier` as a def's + * first argument, so a guarded clause resolved no QN and opened NO function + * scope: every call in its body was attributed to the FILE node, the function + * reported no outgoing edges, and its callees gained an in-edge from the file + * instead of from their caller. A paren-less guarded clause (`def f when g`) is + * worse still: it has no inner call node at all, so the guard operator itself + * falls through to extract_callee_name's first-identifier last resort and mints + * a phantom CALLS edge naming the function being defined. */ +TEST(extract_elixir_guarded_def_head_scope) { + CBMFileResult *r = extract("defmodule Guards do\n" + " def target(x), do: x\n" + " def other(x), do: x\n" + " def unguarded_caller(a), do: target(a)\n" + " def guarded_caller(a) when is_binary(a) do\n" + " target(a)\n" + " other(a)\n" + " end\n" + " def bare_guarded when true, do: target(:ok)\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guard_scope.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* A paren-less guarded head is a definition, never a call to itself. */ + ASSERT_EQ(count_calls_named(r, "bare_guarded"), 0); + + /* Both calls in the guarded body source to the guarded function, and the + * paren-less clause's body call to that clause. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.guarded_caller"), 1); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope.guarded_caller"), 1); + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.bare_guarded"), 1); + + /* Control on the path the fix does not touch: an unguarded clause still + * sources its body call to itself. This is NOT a leak detector -- the walk + * expires scopes by tree depth (pop_expired_scopes), so a sibling clause + * always unwinds the previous one before pushing its own and "pushed but + * never popped" is not a state this walk can reach. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.unguarded_caller"), 1); + + /* Nothing falls back to the file node. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope"), 0); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope"), 0); + + cbm_free_result(r); + PASS(); +} + +/* The half the calls and scope assertions above cannot see: the usages walk + * asks whether an identifier sits inside the def's SIGNATURE, and treats + * everything that does as part of the binding rather than as a reference. A + * guard makes that signature the whole `when` operator, which spans the guard + * expression too -- so `a` read by `is_binary(a)` was classified as its own + * binding and emitted no USAGE row at all. A guard is an expression, not a + * pattern: the parameters are bound by `guarded(a, b)` and every mention of one + * to its right is a read, exactly as the same mention in the body already is. + * Unwrapping the signature to the head restores those rows without admitting + * the binding sites themselves, which is why each name is asserted both within + * its scope and in total. */ +TEST(extract_elixir_guarded_def_head_guard_usages) { + CBMFileResult *r = extract("defmodule GuardUsage do\n" + " def plain(x), do: x\n" + " def guarded(a, b) when is_binary(a) and byte_size(a) > 3 do\n" + " b\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guard_usage.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* Both guard reads of the parameter are usages of the guarded clause. */ + ASSERT_EQ(count_usages_from(r, "a", "t.guard_usage.guarded"), 2); + /* ...and nothing else claims `a`: the `a` in `guarded(a, b)` binds it. */ + ASSERT_EQ(count_usages_named(r, "a"), 2); + + /* The body read is unchanged, and the head's `b` is still a binding -- + * an unwrap that took the guard instead of the head would add both head + * parameters here. */ + ASSERT_EQ(count_usages_from(r, "b", "t.guard_usage.guarded"), 1); + ASSERT_EQ(count_usages_named(r, "b"), 1); + + /* Control: an unguarded clause was never affected either way. */ + ASSERT_EQ(count_usages_from(r, "x", "t.guard_usage.plain"), 1); + ASSERT_EQ(count_usages_named(r, "x"), 1); + + cbm_free_result(r); + PASS(); +} + /* Every clause of an Elixir function is its own `def` call, and an Elixir QN * carries neither module nor arity, so all clauses compute one qualified name * and collide on a single graph node. cbm_gbuf_upsert_node ranks a same-QN @@ -9039,6 +9169,8 @@ SUITE(extraction) { RUN_TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind); RUN_TEST(extract_elixir_real_recursion_survives_head_suppression); RUN_TEST(elixir_call_string_argument); + RUN_TEST(extract_elixir_guarded_def_head_scope); + RUN_TEST(extract_elixir_guarded_def_head_guard_usages); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); RUN_TEST(erlang_function); From 4d21c3bbd34b420c8987e8588b88b5fc5446116f Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 5/5] fix(elixir): stop walking @spec and @type subtrees as code An Elixir module attribute parses as `unary_operator(@, call(, args))`, so the typespec family reaches the unified walk in exactly the shape real code has. `@spec foo(t) :: t` puts `foo(t)` in the same `call` node an invocation would occupy, and `@type entry :: String.t()` does the same to a type name. The walk then reads a declaration as code and mints reference edges from it, sourced at the enclosing Module. Measured on a fixture of three typespec lines (`@type state :: map()`, `@spec fetch(opts) :: state`, `@spec load(User.t()) :: :ok`) beside `def state`, `def opts` and a remote `MyApp.User.t/0`: seven edges, every one phantom and every one landing on a Function. CALLS onto each specified function; CALLS + USAGE + WRITES onto `state`, the WRITES asserting a mutation that does not exist; and USAGE onto `opts` and onto `MyApp.User.t/0`, because a bare or remote TYPE name resolves to the same-named FUNCTION. On a codebase whose convention is @spec on every public function, every specced function carries an inbound edge from its own module, so fan_in never reaches 0 and "which exported functions does nothing call" is unanswerable. Measured by indexing one 970-file Elixir lib tree before and after: every specced function loses the inbound reference its own typespec minted, and no node is added or removed. This is one of two mechanisms. It removes the phantom a typespec line mints; the def-head phantom is removed by the calls walk recognising a `when`-wrapped head as a declaration, earlier in this stack. A function carrying both an @spec and a `when` clause needs both, which is why this commit is ordered after that one and its test can assert 0 references onto such a function rather than pinning a remaining 1. The skip covers the whole subtree, and two costs come with that. Both were measured by indexing a fixture repo (a declaring file per attribute head plus the file each one reaches) with the pristine binary and with this one: A typespec's type references are not indexed at all. That loses nothing correct: a type declaration mints no node, so such a reference resolves onto nothing or onto a same-named FUNCTION, which is itself the pollution. `@spec build(MyApp.User.t())` where that module's `t` is a `@type` produced no edge in either build; the ones that did resolve landed on a same-named Function, cross-file included. @callback and @macrocallback lose a real relationship. The declared name is a function name, so it resolved cross-file onto the functions implementing the behaviour, and nothing replaces that edge: `@behaviour MyBehaviour` mints none of its own. It goes anyway because it is only name resolution: on a fixture with one @callback and three same-named `handle_it/1` defs, the single edge landed on the one module that implements nothing, leaving both real implementors at fan_in 0. Recording that relationship correctly means minting it from `@behaviour`, which is a separate change. A typespec head a widened span covers was also being read as a self-call, which is the other half of the cost the multi-clause span commit disclosed. Same 970-file tree, same method it used (each flagged node's own source span, counting a one-line clause's body after `, do:` as a line that names the function): self_recursive Function nodes 430 -> 389 ...with no self-call form in span 48 -> 8 The base is 129 and 6. All 8 that remain are the phantom a local variable sharing the function's name mints (`defp slug(value) do slug = ...`), which nothing in this stack touches: 6 are on the base as well, and the other 2 are guarded clauses that only have a node of their own to be flagged on because of the earlier commits here. `unquote(...)` inside a typespec does execute at compile time, so `@type t :: unquote(build_type())` loses its genuine `build_type` edge too. Every other attribute VALUE is ordinary compile-time code (`@timeout Application.compile_env(:app, :timeout)` really does call compile_env), so those subtrees are deliberately walked exactly as before, and their attribute NAME still mints one phantom onto a same-named Function. That half of the defect class needs its own fix. tests/test_extraction.c: extract_elixir_typespec_attribute_is_not_code exercises all six heads in the skip list, so deleting any one entry breaks it. It pins the two accepted costs as decisions, and it carries a control name (`without_spec`) that reads 0 on the pristine build too, so an instrument that scored a definition as a reference could not make the other zeroes vacuous. extract_elixir_declaration_head_mints_no_reference_of_any_kind tightens from a relation to 0: with both mechanisms present, a function with an @spec and a guard carries no reference of any kind. Depends on the guarded call-scope commit, for the ordering above. It touches no file the earlier commits in this stack touch except extract_unified.c, and none of their lines. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/extract_defs.c | 15 +++- internal/cbm/extract_unified.c | 110 ++++++++++++++++++++++++++- tests/test_extraction.c | 135 ++++++++++++++++++++++++++++----- 3 files changed, 235 insertions(+), 25 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 839e482de..2faf10856 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5477,9 +5477,18 @@ static TSNode elixir_call_args(TSNode node) { // responsible are the typespec heads the widened span now covers -- `@spec // f(t) :: u` puts the declared name in a `call` node, and the unified walk // reads it as code -- a separate defect with a separate fix, not something this -// fold can close. What the fold and the head suppression together remove is -// 2,436 edges, none of them added back: see elixir_call_is_definition_role in -// extract_calls.c. +// fold can close. +// +// Skipping the typespec subtrees, later in this stack, closes that half: the +// same method then reports 389 self_recursive Function nodes and 8 with no +// self-call form, against 129 and 6 on the base. All 8 are the pre-existing +// phantom a local variable sharing the function's name mints (`defp slug(value) +// do slug = ...`), 6 of them already present on the base; the other 2 are +// guarded clauses that only have a node of their own to be flagged on because +// of this stack. +// +// What the fold and the head suppression together remove is 2,436 edges, none +// of them added back: see elixir_call_is_definition_role in extract_calls.c. // // A clause whose macro differs (`def` foo/1 beside `defp` foo/2) still folds, // because the arity-free QN already puts both on one node; is_exported is then diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 68e0fbdcc..9467d82e6 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -2041,6 +2041,111 @@ static bool is_unified_trivia_node(TSNode node) { return ts_node_is_extra(node); } +static bool unified_node_text_equals(const CBMExtractCtx *ctx, TSNode node, const char *expected) { + if (ts_node_is_null(node) || !ctx->source || !expected) { + return false; + } + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + size_t len = strlen(expected); + return end >= start && (size_t)(end - start) == len && + memcmp(ctx->source + start, expected, len) == 0; +} + +/* An Elixir module attribute parses as `unary_operator(@, call(, args))`, + * so the typespec family reaches the unified walk in the shape real code has: + * `@spec foo(t) :: t` puts `foo(t)` in the same `call` node an invocation would + * occupy, and `@type entry :: String.t()` does the same to a type name. The + * walk then reads a declaration as code and mints reference edges from it. + * + * Measured on a fixture of three typespec lines (`@type state :: map()`, `@spec + * fetch(opts) :: state`, `@spec load(User.t()) :: :ok`) beside `def state`, + * `def opts` and a remote `MyApp.User.t/0`: seven edges, every one phantom and + * every one landing on a Function. CALLS onto each specified function; + * CALLS + USAGE + WRITES onto `state`, the WRITES asserting a mutation that + * does not exist; and USAGE onto `opts` and onto `MyApp.User.t/0`, because a + * bare or remote TYPE name resolves to the same-named FUNCTION. On a codebase + * whose convention is @spec on every public function, fan_in therefore never + * reaches 0 and "which exported functions nothing calls" is unanswerable. + * + * The whole subtree is skipped, not just the specified name. What that costs is + * not uniform across the six heads, and both halves were measured by indexing + * one fixture repo -- a declaring file per head plus the file each one reaches + * -- with the pristine binary and with this one: + * + * spec, type, typep, opaque. A type reference resolves onto a FUNCTION or + * onto nothing, never onto the type it names, because a type declaration + * mints no node. `@spec build(MyApp.User.t())` where that module's `t` is a + * `@type` produced no edge in either build, and `@type ext :: Ecto.Schema.t()` + * naming a module outside the repo produced none either. The references that + * do resolve land on a same-named Function, cross-file included -- `USAGE + * lib/decl_type.ex -> Function type_target @lib/remote_type.ex`, and the + * @typep and @opaque equivalents. A Function standing in for a Type is the + * pollution rather than a record of it, so nothing correct is lost here. + * + * callback, macrocallback. The declared name IS a function name, so it + * resolves onto an implementation elsewhere in the corpus: pristine yields + * `CALLS lib/decl_callback.ex -> Function callback_fun @lib/impl_callback.ex` + * and the @macrocallback equivalent, both cross-file, and this build yields + * neither. That edge does point at a real Behaviour-to-Impl relationship, and + * nothing replaces it -- `@behaviour MyBehaviour` mints no edge of its own, + * so after this change nothing in the graph links a behaviour to its + * implementations. It is deleted anyway because it is name resolution and not + * a behaviour model: on a fixture with one @callback and three same-named + * `handle_it/1` defs, the single edge landed on the one module that + * implements nothing, leaving both real implementors at fan_in 0. Recording + * that relationship correctly means minting it from `@behaviour`, which is a + * separate change and not a reason to keep an arbitrary edge. + * + * The other accepted cost: `unquote(...)` inside a typespec does execute at + * compile time, so `@type t :: unquote(build_type())` loses its genuine + * `build_type` call edge along with the phantoms. + * + * Only this family is skipped. Every other attribute VALUE is ordinary + * compile-time code -- `@timeout Application.compile_env(:app, :timeout)` + * really does call compile_env -- so those subtrees are walked exactly as + * before, and their attribute NAME is still emitted as a callee: `@behaviour + * GenServer`, `@timeout 5_000` and `@doc "x"` each still mint one phantom CALLS + * onto a same-named Function. That half of the defect class is untouched here + * and needs its own fix. + * + * This subtree skip is one of two mechanisms, and neither subsumes the other. + * It removes the phantom a typespec LINE mints; the def-head phantom is removed + * separately, by the calls walk recognising a `when`-wrapped head as a + * declaration (elixir_call_is_definition_role, extract_calls.c). A function + * carrying both an @spec and a `when` clause needs both: with only this one it + * keeps the def-head reference, with only the other it keeps the @spec one. + * `extract_elixir_typespec_attribute_is_not_code` pins that by asserting 0 + * references onto such a function, so removing either mechanism fails it. + * + * Measured over forge-symphony-graph/lib (970 .ex files) by classifying the + * source line every CALLS/USAGE/WRITES edge records: a build without this skip + * anchors 4,565 CALLS edges on a typespec-attribute line, and this build + * anchors 0 -- with no USAGE or WRITES appearing in their place, which is what + * a relabel rather than a removal would look like. Total USAGE edges fall from + * 25,393 to 23,044 over the same corpus; they do not rise. */ +static bool is_elixir_typespec_attribute(const CBMExtractCtx *ctx, TSNode node) { + static const char *const typespec_heads[] = { + "spec", "callback", "macrocallback", "type", "typep", "opaque", NULL}; + if (ctx->language != CBM_LANG_ELIXIR || strcmp(ts_node_type(node), "unary_operator") != 0 || + !unified_node_text_equals(ctx, ts_node_child_by_field_name(node, TS_FIELD("operator")), + "@")) { + return false; + } + TSNode operand = ts_node_child_by_field_name(node, TS_FIELD("operand")); + if (ts_node_is_null(operand) || strcmp(ts_node_type(operand), "call") != 0 || + ts_node_child_count(operand) == 0) { + return false; + } + TSNode head = ts_node_child(operand, 0); + for (const char *const *name = typespec_heads; *name; name++) { + if (unified_node_text_equals(ctx, head, *name)) { + return true; + } + } + return false; +} + // JS/TS `export_statement` appears in import_node_types so re-exports // (`export { X } from './m'`) are treated as an import boundary. But it also // wraps exported *declarations* (`export function f(cfg: Config) {}`), and @@ -2631,7 +2736,8 @@ void cbm_extract_unified(CBMExtractCtx *ctx) { break; } bool trivia = is_unified_trivia_node(node); - if (!trivia) { + bool typespec = is_elixir_typespec_attribute(ctx, node); + if (!trivia && !typespec) { /* Trivia consumes no semantic state. Scope expiry may be deferred * until the next code-bearing node; pop restores the displaced * tuple and push applies the new frame's effect, both O(1) -- @@ -2661,7 +2767,7 @@ void cbm_extract_unified(CBMExtractCtx *ctx) { * asking the cursor to construct a child iterator for every one of * hundreds of thousands of flat comment siblings. Structured extras * still descend normally. */ - if ((!trivia || ts_node_child_count(node) > 0) && + if (!typespec && (!trivia || ts_node_child_count(node) > 0) && ts_tree_cursor_goto_first_child(&cursor)) { depth++; continue; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 1adca8bce..3f6544853 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1847,24 +1847,22 @@ static int elixir_any_ref_count(CBMFileResult *r, const char *name) { } /* The head suppression has to REMOVE the phantom, not relabel it. What makes - * that hold for a def head is not the calls walk alone: is_elixir_def_binding - * in extract_usages.c already treats everything inside a def's first argument - * as a binding occurrence rather than a reference, so the identifier - * handle_calls declines is declined by handle_usages too. That agreement is the - * invariant under test, and it is why the counts below are over every extractor - * rather than over r->calls. + * that hold for a def head is not the calls walk alone: is_elixir_def_binding in + * extract_usages.c already treats everything inside a def's first argument as + * a binding occurrence rather than a reference, so the identifier handle_calls + * declines is declined by handle_usages too. That agreement is the invariant + * under test, and it is why the counts below are over every extractor rather + * than over r->calls. * - * `spec_subject` pins the boundary of this patch, and pins it as a RELATION - * rather than a count, because the count cannot see the defect. A typespec - * subject is the same shape as a def head and is NOT suppressed here: nothing - * in extract_usages.c treats it as a binding, so declining it in handle_calls - * only relabels its phantom as a USAGE. At extraction that relabel is - * invisible -- one reference before, one after -- and it becomes a defect - * downstream, where CALLS and USAGE are separate rows under - * UNIQUE(source,target,type) and the relabelled phantom stops colliding with - * the real call it used to hide behind. So the assertion is that every - * reference recorded for a typespec subject is a CALL and none has become a - * usage, a read/write or a type reference. */ + * `spec_subject` pins the boundary between the two mechanisms. A typespec + * subject is the same shape as a def head, and suppressing it in the calls walk + * would only relabel its phantom as a USAGE, because nothing treats it as a + * binding. It is removed instead by skipping the whole `@spec` subtree before + * any extractor sees it (is_elixir_typespec_attribute, extract_unified.c), so + * the assertion is both that no reference of any kind survives AND that what + * does survive is never a relabelling: the equality holds at 0 == 0 here, and + * would hold at 1 == 1 on a build with the subtree skip removed but the calls + * suppression left in place. */ TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind) { CBMFileResult *r = extract("defmodule Heads do\n" /* 1 */ " def guarded(x) when is_integer(x), do: x\n" /* 2 */ @@ -1886,11 +1884,19 @@ TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind) { /* The guard predicate is a real call and survives as one. */ ASSERT_EQ(1, count_calls_named(r, "is_integer")); ASSERT_EQ(1, elixir_any_ref_count(r, "is_integer")); - /* Declared, not suppressed here; see the comment above. Every reference - * this file records for a typespec subject is a CALL -- none of them has - * been relabelled into a usage, a read/write or a type reference. */ + /* Neither the @spec line nor the def head it precedes leaves a reference, + * and nothing has been relabelled into a usage, a read/write or a type + * reference. */ + ASSERT_EQ(0, count_calls_named(r, "spec_subject")); ASSERT_EQ(count_calls_named(r, "spec_subject"), elixir_any_ref_count(r, "spec_subject")); + + ASSERT_EQ(1, count_defs_named(r, "Function", "guarded")); + const CBMDefinition *guarded = elixir_first_def(r, "guarded"); + ASSERT_NOT_NULL(guarded); + ASSERT_EQ(2, (int)guarded->start_line); + ASSERT_EQ(3, (int)guarded->end_line); + ASSERT_FALSE(guarded->is_recursive); cbm_free_result(r); PASS(); } @@ -1919,6 +1925,94 @@ TEST(extract_elixir_real_recursion_survives_head_suppression) { PASS(); } +/* An Elixir module attribute parses as `unary_operator(@, call(, args))`, + * so a typespec line reaches the unified walk as ordinary code and every symbol + * it names collects a phantom inbound edge from the enclosing Module -- a CALLS + * onto the specified function, and for `@type` a USAGE plus a WRITES asserting + * a mutation that does not exist. On a codebase whose convention is @spec on + * every public function, fan_in then never reaches 0 and "which exported + * functions nothing calls" is unanswerable. + * + * All six heads in the skip list are exercised, so deleting any one entry from + * typespec_heads[] breaks this test. + * + * Two of the groups below pin a decision rather than a repair, and both are + * argued in is_elixir_typespec_attribute's comment: + * + * type_refs[] -- a typespec's type references are not indexed at all. A type + * declaration mints no node, so such a reference resolves onto nothing or + * onto a same-named FUNCTION, which is the pollution rather than a record of + * it. Restoring any of them must break this test. + * + * behaviour_contract_names[] -- accepted cost. A @callback / @macrocallback + * name is a function name, so before this change it resolved cross-file onto + * the functions implementing the behaviour. Nothing replaces that edge. + * + * The attribute NAME phantom outside the typespec family is deliberately still + * there: `@timeout` keeps its one reference, and its VALUE is ordinary + * compile-time code that still runs. `guarded` is pinned at 0 rather than 1 + * because the guarded def head no longer mints a phantom either -- the calls + * walk now recognises a `when`-wrapped head as a declaration. */ +TEST(extract_elixir_typespec_attribute_is_not_code) { + /* Names a typespec declares, plus the six attribute heads themselves. + * Every one carries a reference on the pristine build. */ + static const char *const declared[] = {"with_spec", "entry", "secret", "handle", + "spec", "type", "typep", "opaque", + "callback", "macrocallback", NULL}; + /* Accepted cost: these used to resolve onto the behaviour's implementors. */ + static const char *const behaviour_contract_names[] = {"handle_it", "expand_it", NULL}; + /* Pinned decision: type references are not indexed -- remote, bare-local + * and builtin alike. */ + static const char *const type_refs[] = {"MyApp.User.t", "String.t", "MyApp.Vault.key", + "Macro.t", "t", "key", + "term", "integer", "reference", + "atom", NULL}; + CBMFileResult *r = extract("defmodule Specced do\n" + " @timeout Application.compile_env(:app, :timeout)\n" + " @type entry :: String.t()\n" + " @typep secret :: MyApp.Vault.key()\n" + " @opaque handle :: reference()\n" + " @callback handle_it(term) :: :ok\n" + " @macrocallback expand_it(term) :: Macro.t()\n" + " @spec with_spec(MyApp.User.t()) :: integer\n" + " def with_spec(n), do: n\n" + " def without_spec(n), do: n\n" + " @spec guarded(atom) :: :ok\n" + " def guarded(x) when is_atom(x), do: :ok\n" + "end\n", + CBM_LANG_ELIXIR, "t", "specced.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* The definitions themselves are a separate pass and are unaffected. */ + ASSERT(has_def(r, "Function", "with_spec")); + ASSERT(has_def(r, "Function", "without_spec")); + ASSERT(has_def(r, "Function", "guarded")); + /* Control on the instrument, not a pin: `without_spec` carries no typespec + * and is never called, so it reads 0 on the pristine build too. It is here + * to show elixir_any_ref_count does not score a definition as a reference, + * which is what would make every 0 below vacuous. It cannot fail first and + * cannot catch a regression. */ + ASSERT_EQ(0, elixir_any_ref_count(r, "without_spec")); + for (const char *const *name = declared; *name; name++) { + ASSERT_EQ(0, elixir_any_ref_count(r, *name)); + } + for (const char *const *name = behaviour_contract_names; *name; name++) { + ASSERT_EQ(0, elixir_any_ref_count(r, *name)); + } + for (const char *const *name = type_refs; *name; name++) { + ASSERT_EQ(0, elixir_any_ref_count(r, *name)); + } + /* A non-typespec attribute is untouched: its value really is compile-time + * code, and its name still mints the one phantom this fix does not close. */ + ASSERT_EQ(1, count_calls_named(r, "Application.compile_env")); + ASSERT_EQ(1, elixir_any_ref_count(r, "timeout")); + /* Both phantoms onto `guarded` are gone: the @spec one to the subtree skip, + * the def-head one to the calls walk recognising a guarded head. */ + ASSERT_EQ(0, elixir_any_ref_count(r, "guarded")); + cbm_free_result(r); + PASS(); +} + /* tree-sitter-elixir gives a call's arguments node no field name, so the * generic `arguments` field lookup returns null and first_string_arg was never * populated for any Elixir call — Phoenix route paths, service URLs and config @@ -9168,6 +9262,7 @@ SUITE(extraction) { RUN_TEST(extract_elixir_guarded_clause_head_is_not_a_self_call); RUN_TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind); RUN_TEST(extract_elixir_real_recursion_survives_head_suppression); + RUN_TEST(extract_elixir_typespec_attribute_is_not_code); RUN_TEST(elixir_call_string_argument); RUN_TEST(extract_elixir_guarded_def_head_scope); RUN_TEST(extract_elixir_guarded_def_head_guard_usages);