From dd5db97fe28f9cb018a19562948a0bf37af46b90 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 01/13] 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 50c75bdf2e..6ddd8ceaf5 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 905de04a8d..c18f27429b 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 1e6506ed1a..a1dc4118b0 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 @@ -8387,6 +8550,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 1474716d0199086fdd61e35b1a181a699aa02986 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 02/13] 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 e783f73d 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,812 -> 57,090 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 916f1136f9..f77eb5db1f 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5421,6 +5421,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 a1dc4118b0..94309a3c61 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 @@ -8551,6 +8599,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 6f2df2312237f8339059c73942275651335142b3 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 03/13] 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 e783f73d and with this one, and diffing the `nodes` and `edges` rows out of the two SQLite stores: 2,311 functions stop being reported as one line of source, 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 and looking for any non-declaration line that names it as a call, a capture or a pipe target: self_recursive Function nodes 129 -> 349 ...with no self-call form in span 24 -> 86 The lines responsible are the @spec and @doc heads a widened span now covers, which the unified walk reads 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`. 777 of them do; none changes its source, target or type. The one CONFIGURES edge removed was minted from `defp tls_unconfigured(%{tls_port: port, trust_anchor: anchor})`, whose head was read as a configuration call, so removing that edge is correct. 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 | 57 +++++- internal/cbm/extract_defs.c | 127 ++++++++++++- tests/test_extraction.c | 338 +++++++++++++++++++++++++++++++++++ 3 files changed, 514 insertions(+), 8 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index e1de896b8c..4369865bf7 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -708,6 +708,61 @@ 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 + * e783f73d and with this one and diffing the `edges` rows out of the two + * SQLite stores, keyed on (source QN, target QN, type): 2,303 edges go and + * NONE is added -- 1,990 CALLS, 312 USAGE, and one CONFIGURES minted from + * `defp tls_unconfigured(%{tls_port: port, trust_anchor: anchor})`, whose head + * was read as a configuration call. Of the 116,591 surviving edges, 777 record + * a different `line` and none changes its source, target or type. + * + * 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 +780,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 f77eb5db1f..0b9cff73ca 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5408,8 +5408,107 @@ 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 e783f73d and with this one +// and diffing the `nodes` rows out of the two SQLite stores: 4,342 +// Function nodes widen their span, none is added and none is lost, and +// 131 of the 4,342 now cover a module-body line that is not part of a +// clause. Counting each node once per kind, 69 cover a typespec attribute +// (@spec/@type/@typep/@opaque/@callback/@macrocallback), 53 an @doc or +// @typedoc, 15 an @impl, 59 a bare comment line, and 4 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, +// same method, 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: +// self_recursive Function nodes go 129 -> 349, and the ones carrying no +// self-call form go 24 -> 86. The lines responsible are the @spec and @doc +// heads the widened span now covers, which are read as code by the unified +// walk -- a separate defect with a separate fix, not something this fold can +// close. What the fold and the head suppression together remove is 2,303 +// 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)) { @@ -5442,15 +5541,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); } @@ -5487,6 +5596,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); @@ -5509,7 +5622,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 94309a3c61..44de5d8f95 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 @@ -8600,6 +8932,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 370fa5829f80d219e66d12900330f63d6652169f 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 04/13] 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 e783f73d 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 e783f73d, 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,053 -> 5,840 (-5,213) CALLS sourced from a Function node 42,629 -> 49,542 (+6,913) CALLS sourced from a File node 2,130 -> 1,269 (-861) 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. 5,275 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 returns to 11,220. 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 | 18 ++++- internal/cbm/extract_unified.c | 5 ++ internal/cbm/extract_usages.c | 5 ++ tests/test_extraction.c | 132 +++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 4369865bf7..0335258925 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -802,7 +802,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 086f501a0c..68e0fbdcc6 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 f6b3913151..57594c665c 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 44de5d8f95..f68db7bab2 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 @@ -8939,6 +9069,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 ae3fb4c592f100497940bd6fb6948b627420855d 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 05/13] 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. `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_unified.c | 110 ++++++++++++++++++++++++++- tests/test_extraction.c | 135 ++++++++++++++++++++++++++++----- 2 files changed, 223 insertions(+), 22 deletions(-) diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 68e0fbdcc6..9467d82e6c 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 f68db7bab2..a969f15c67 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 @@ -9068,6 +9162,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); From 96a4f71597bb3ac6ff383c02f7810509295cb4d9 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 06/13] fix(registry): index a symbol under its own name and its QN's tail cbm_registry_add takes the symbol's `name`, discards it, and derives the bare-name lookup key from the QN's last dot segment. That makes the QN's tail load-bearing for the by-name index every language shares. The tail is already wrong for one of those languages: rust_cfg_qualified_name mints a `#[cfg(test)]` twin as "proj.lib.add#cfg(test)", simple_name() has no '#' handling, so that function is indexed under the literal string "add#cfg(test)" where no bare `add` callee can reach it. The same hole would swallow every arity-fenced Elixir definition the later commits in this stack mint. The caller always knows the name; it is the argument. But the two keys disagree in both directions, so swapping one for the other only moves the hole. A name can equally carry segments the QN's tail drops: grammar def->name QN tail HCL resource.aws_instance.web web TOML tool.poetry.dependencies dependencies INI tool.isort isort Markdown 1.2 Scope 2-Scope Elixir Fx.Store Store all 162 helper.py helper (the file's Module node) The last row is the broad one: a file's Module node is named with the basename including its extension in every grammar, while its QN tail is the extensionless stem a bare module reference is written as. The HCL row is the one with a live reference syntax behind it: `instance = aws_instance.web.id` reaches the block through the tail `web`, and a name-only key drops that edge outright. So the index carries both keys, and pays for the second only where they differ. No key is removed, so no lookup that resolved before stops resolving. `name` is NULL or empty only for callers that have no symbol name to give; those have the derived key and nothing else. A throwaway probe walked tests/grammar_cases.h (one fixture per grammar, all 162) and compared def->name with simple_name(def->qualified_name), then targeted fixtures for the dotted shapes above. The Module row appears in all 162; HCL is the only fixture-level mismatch beyond it; TOML, INI, Markdown and Elixir mismatch on the targeted fixtures. XML namespaced elements (`ns:root`) do not mismatch: ':' is not a QN separator. tests/test_registry.c: registry_indexes_by_passed_name_not_qn_tail resolves a bare `add` against a cfg-fenced QN. registry_indexes_a_dotted_name_under_its_tail_too resolves an HCL block by its tail and a Module node by its stem. tests/test_pipeline.c: pipeline_hcl_block_reference_resolves_to_its_block indexes a two-resource Terraform file end to end and asserts the surviving USAGE edge. registry_indexes_by_passed_name_not_qn_tail fails without this commit. The other two pass on e783f73d and are here as guards: what would break them is the by-name index this stack re-keys, and an HCL block name is dotted where its QN tail is not, so they pin the shape that regression would take. It has no content dependency on any commit below it: it touches src/pipeline/registry.c plus two test files that none of them touch, and it applies to e783f73d cleanly. It is ordered before the module/arity identity commit because the arity-fenced definitions that commit mints need the passed-name key to stay reachable from a bare callee. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- src/pipeline/registry.c | 76 +++++++++++++++++++++++++++-------------- tests/test_pipeline.c | 55 +++++++++++++++++++++++++++++ tests/test_registry.c | 50 +++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 25 deletions(-) diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index c99c72d8d5..42346515cb 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -858,9 +858,38 @@ void cbm_registry_free(cbm_registry_t *r) { /* ── Registration ────────────────────────────────────────────────── */ +/* Record `owned_qn` in the by-name bucket for `key`, keeping the bucket's + * is_test flags in step with its entries. + * + * No array dedup needed: cbm_registry_add's exact-map check guarantees the QN + * is new, and it calls this at most once per distinct key. */ +static void index_under_name(cbm_registry_t *r, const char *key, const char *owned_qn) { + qn_array_t *arr = cbm_ht_get(r->by_name, key); + if (!arr) { + arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t)); + cbm_ht_set(r->by_name, strdup(key), arr); + } + int before = arr->count; + cbm_da_push(arr, (char *)owned_qn); + if (arr->count == before) { + return; /* the name could not be recorded: no verdict to cache */ + } + if (arr->count > arr->is_test_cap) { + int want = arr->cap > 0 ? arr->cap : arr->count; + uint8_t *grown = + cbm_realloc(CBM_MEM_CLASS_DYN_ARRAY, arr->is_test, (size_t)want * sizeof(uint8_t)); + if (grown) { + arr->is_test = grown; + arr->is_test_cap = want; + } + } + if (arr->count <= arr->is_test_cap) { + arr->is_test[arr->count - SKIP_ONE] = is_test_qn(owned_qn) ? 1 : 0; + } +} + void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label) { - (void)name; if (!r || !qualified_name || !label) { return; } @@ -893,30 +922,27 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified cbm_ht_set(r->exact, strdup(qualified_name), (void *)interned); const char *owned_qn = cbm_ht_get_key(r->exact, qualified_name); - /* Index by simple name. - * No array dedup needed: exact-map check above guarantees uniqueness. */ - const char *simple = simple_name(qualified_name); - qn_array_t *arr = cbm_ht_get(r->by_name, simple); - if (!arr) { - arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t)); - cbm_ht_set(r->by_name, strdup(simple), arr); - } - int before = arr->count; - cbm_da_push(arr, (char *)owned_qn); - if (arr->count == before) { - return; /* the name could not be recorded: no verdict to cache */ - } - if (arr->count > arr->is_test_cap) { - int want = arr->cap > 0 ? arr->cap : arr->count; - uint8_t *grown = - cbm_realloc(CBM_MEM_CLASS_DYN_ARRAY, arr->is_test, (size_t)want * sizeof(uint8_t)); - if (grown) { - arr->is_test = grown; - arr->is_test_cap = want; - } - } - if (arr->count <= arr->is_test_cap) { - arr->is_test[arr->count - SKIP_ONE] = is_test_qn(owned_qn) ? 1 : 0; + /* Index the symbol under the name its caller passed AND under the QN's + * last dot segment, whenever the two differ. + * + * Neither key alone covers every language, because the two disagree in + * both directions. A QN can carry a discriminator the name does not: a + * Rust cfg twin is minted as "add#cfg(test)" (rust_cfg_qualified_name) + * and simple_name() has no '#' handling, so the derived key alone filed + * that function under the literal string "add#cfg(test)", where no bare + * `add` callee could reach it. A name can equally carry dots the QN's + * tail drops: an HCL block names itself "resource.aws_instance.web" + * (find_hcl_block_name) and a TOML dotted table key names itself + * "tool.poetry.dependencies", so the passed key alone loses the tail + * lookup that an `aws_instance.web.id` reference resolves through. + * + * `name` is NULL or empty only for callers that have no symbol name to + * give; those have the derived key and nothing else. */ + const char *derived = simple_name(qualified_name); + const char *given = (name && name[0]) ? name : derived; + index_under_name(r, given, owned_qn); + if (strcmp(given, derived) != 0) { + index_under_name(r, derived, owned_qn); } } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d32ab3538b..1243564700 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1117,6 +1117,60 @@ TEST(pipeline_nix_scoped_binding_calls_resolve) { PASS(); } +/* Terraform reference resolution, end to end. + * + * An HCL block names itself with its labels appended -- find_hcl_block_name + * mints "resource.aws_instance.web" -- while its QN's last dot segment is + * bare "web", and a reference written `aws_instance.web.id` reaches the + * by-name index through that tail. HCL is therefore a language where the + * definition name and the QN tail are different strings, and an index keyed on + * either one alone drops every cross-resource reference in the file. + * + * This has to be a pipeline test: the registry is shared by every language + * and nothing in the HCL extractor mentions the index key, so the two halves + * can disagree with every extraction-level assertion still green. + */ +TEST(pipeline_hcl_block_reference_resolves_to_its_block) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + char tf_path[512]; + snprintf(tf_path, sizeof(tf_path), "%s/main.tf", g_tmpdir); + FILE *tf = fopen(tf_path, "w"); + if (!tf) { + teardown_test_repo(); + FAIL("failed to write terraform fixture"); + } + fprintf(tf, "resource \"aws_instance\" \"web\" {\n" + " ami = \"ami-0c55b159cbfafe1f0\"\n" + " instance_type = \"t2.micro\"\n" + "}\n" + "\n" + "resource \"aws_eip\" \"ip\" {\n" + " instance = aws_instance.web.id\n" + "}\n"); + fclose(tf); + + char tf_db[512]; + snprintf(tf_db, sizeof(tf_db), "%s/test_hcl_refs.db", g_tmpdir); + + cbm_pipeline_t *tp = cbm_pipeline_new(g_tmpdir, tf_db, CBM_MODE_FULL); + ASSERT_NOT_NULL(tp); + ASSERT_EQ(cbm_pipeline_run(tp), 0); + + cbm_store_t *ts = cbm_store_open_path(tf_db); + ASSERT_NOT_NULL(ts); + const char *tf_project = cbm_pipeline_project_name(tp); + + ASSERT(cross_file_edge_exists(ts, tf_project, "main", "resource.aws_instance.web", "USAGE")); + + cbm_store_close(ts); + cbm_pipeline_free(tp); + teardown_test_repo(); + PASS(); +} + /* Regression: incremental re-index of an edited file must NOT drop inbound * cross-file CALLS edges whose source lives in an UNCHANGED file. * @@ -15098,6 +15152,7 @@ SUITE(pipeline) { /* Calls pass */ RUN_TEST(pipeline_calls_resolution); RUN_TEST(pipeline_nix_scoped_binding_calls_resolve); + RUN_TEST(pipeline_hcl_block_reference_resolves_to_its_block); RUN_TEST(pipeline_incremental_preserves_cross_file_calls); RUN_TEST(pipeline_objectscript_export_preserves_calls_sequential_parallel); RUN_TEST(pipeline_objectscript_export_incremental_matches_full_relationships); diff --git a/tests/test_registry.c b/tests/test_registry.c index 5f751a8993..1ef56f64c6 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -325,6 +325,54 @@ TEST(resolve_qualified_ambiguous_tail_falls_through) { PASS(); } +/* cbm_registry_add used to discard its `name` argument and re-derive the + * lookup key from the QN's last dot segment. That made the QN's tail load + * bearing for the bare-name index every language shares: a Rust cfg twin, + * minted as "add#cfg(test)", was indexed under that literal string and no bare + * `add` callee could ever reach it. */ +TEST(registry_indexes_by_passed_name_not_qn_tail) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "add", "proj.lib.add#cfg(test)", "Function"); + + cbm_resolution_t res = cbm_registry_resolve(r, "add", "proj.lib.caller", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.lib.add#cfg(test)"); + + cbm_registry_free(r); + PASS(); +} + +/* The other direction of the same disagreement: a name may carry segments that + * the QN's tail drops, and then the QN tail is the key callers actually spell. + * + * Two shapes, both taken from what the extractors emit today. An HCL block is + * named "resource.aws_instance.web" by find_hcl_block_name while its QN tail + * is bare "web", which is how a `aws_instance.web.id` reference reaches it. A + * file's Module node is named with the basename INCLUDING its extension in + * every language, while its QN tail is the extensionless stem + * a bare module reference is written as. + * + * Keying the index on the passed name ALONE loses both lookups, so the index + * carries both keys. */ +TEST(registry_indexes_a_dotted_name_under_its_tail_too) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "resource.aws_instance.web", "proj.main.resource.aws_instance.web", + "Class"); + cbm_registry_add(r, "helper.py", "proj.pkg.helper", "Module"); + + cbm_resolution_t tail = cbm_registry_resolve(r, "web", "proj.main", NULL, NULL, 0); + ASSERT_STR_EQ(tail.qualified_name, "proj.main.resource.aws_instance.web"); + + cbm_resolution_t whole = + cbm_registry_resolve(r, "resource.aws_instance.web", "proj.main", NULL, NULL, 0); + ASSERT_STR_EQ(whole.qualified_name, "proj.main.resource.aws_instance.web"); + + cbm_resolution_t stem = cbm_registry_resolve(r, "helper", "proj.pkg.caller", NULL, NULL, 0); + ASSERT_STR_EQ(stem.qualified_name, "proj.pkg.helper"); + + cbm_registry_free(r); + PASS(); +} + TEST(resolve_import_map) { cbm_registry_t *r = cbm_registry_new(); cbm_registry_add(r, "Process", "proj.pkg.worker.Process", "Function"); @@ -1201,6 +1249,8 @@ SUITE(registry) { RUN_TEST(resolve_same_module); RUN_TEST(resolve_qualified_disambiguates_same_name); RUN_TEST(resolve_qualified_ambiguous_tail_falls_through); + RUN_TEST(registry_indexes_by_passed_name_not_qn_tail); + RUN_TEST(registry_indexes_a_dotted_name_under_its_tail_too); RUN_TEST(resolve_import_map); RUN_TEST(resolve_import_map_bare_function); RUN_TEST(resolve_import_map_bare_alias); From 44b478193d345e6d5b9edd331df48cd0876ff6ab Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:23 -0700 Subject: [PATCH 07/13] feat(elixir): key function nodes by (module, name, arity) An Elixir qualified name was the file path plus the bare function name. It carried neither the module written in the source nor the arity, and Elixir identifies a function by both: `Fx.Store.fetch/1` and `Fx.Store.fetch/3` differ by arity and are two functions, not clauses of one, and `Fx.Store.fetch/1` and `Fx.Other.fetch/1` are two functions that a single-file-per-module convention happens to put in different files. Every one of them computed the same QN and collapsed onto one graph node, so the caller sets of unrelated functions were merged and get_code_snippet returned whichever one won the collision. cbm_elixir_def_head / cbm_elixir_def_qn replace two hand-written copies of the same parse, one in extract_defs.c (naming the definition) and one in extract_unified.c (opening the call scope), which had already drifted: only the def side unwrapped the `when` guard until earlier commits in this stack pointed both at the shared helper. They must emit a byte-identical string, because a call is joined to its source node by exact QN and falls back to the File node on a miss, so a one-segment disagreement silently reattributes every edge sourced inside an Elixir function. A def's QN is now `proj.file.Module.name#arity`, and an `elixir_frame_t` stack threads the enclosing defmodule QN down the walk so a nested module composes (`Outer.Inner`). push_boundary_scopes pushes the matching module scope, which it could not do before: `defmodule` is a `call`, so compute_func_qn correctly returned NULL for it and the class branch was never reached. The fence character is '#', following rust_cfg_qualified_name's precedent ("add#cfg(test)"). It cannot be a dot segment: '.' is structurally reserved as the QN separator across the whole codebase (simple_name, qualified_suffix_match, the `LIKE '%.'||suffix` store probe, ~90 strrchr(qn, '.') sites). Only an all-digit tail counts as a fence, so the Rust cfg twin is not mistaken for one. The arity trait is enum-keyed rather than a CBMLangSpec field, for the build reason recorded at lang_specs.h:47. src/foundation/constants.h gains exactly the two names this commit uses: CBM_ARITY_NONE, the sentinel a QN with no fence and a call site with no usable argument count both report, and CBM_LAST_OFFSET, which the clause fold needs to index the definition it just pushed. param_count travels with this because it is the one seam here that could not be cut. The generic post-pass in cbm.c derives a parameter count from the signature or the parameter list, and Elixir has neither: the arity is structural. It zeroed what the extractor had just set, which is the only record of arity outside the QN and feeds the structural-smell metric. Eight lines, and extract_elixir_clauses_of_different_arity_do_not_fold fails without them, so splitting it out would mean a commit whose own test cannot pass. The container and arity halves are separable in principle and are deliberately not separated. They are the same three lines of QN composition. An intermediate state that carries the module but not the arity still merges fetch/1 with fetch/3, so its own tests would have to pin the defect the next commit removes. Each half rewrites every Elixir QN assertion in the suite, so splitting costs two mechanical rewrites of the same assertions to buy one commit that fixes half a defect. The change is 788 added lines, of which 220 are in tests/test_extraction.c and 41 are two repro harnesses learning to strip a fence. The counts below come from building a product binary from the by-name index commit and one from this commit, indexing the same 970-file Elixir lib tree with each into its own sandbox (`cli index_repository --mode full`), and counting rows in the two SQLite stores. Both stores report 970 File nodes, which pins the corpus: Function nodes 21,941 -> 22,226 CALLS edges 50,193 -> 54,129 CALLS edges sourced from a Function 49,483 -> 53,375 The Function rise is the collision leaving: 285 net new nodes where one name used to answer for several arities or several modules. CALLS rises with it, because a call that used to land on whichever clause won the collision now lands on a node that exists. Two rows a pristine comparison would show are deliberately absent from that table, because they do not move here. Module-sourced CALLS/USAGE/WRITES reads 17,207 on e783f73d and 729 already at the by-name index commit; it is 759 after this commit. Distinct CALLS targets from a Module reads 8,224 on e783f73d, 401 at the by-name index commit, and 401 after this commit. The extraction commits earlier in this stack own that attribution collapse. Attributing it here would be measuring the stack and calling it the commit. CBM_INDEX_FORMAT_VERSION goes 1 -> 2: every Elixir QN in an existing store has a different shape, so a stale index must be rebuilt rather than merged. cbm_enclosing_func_qn gains an Elixir branch. Elixir has no class_node_types and every construct is a `call`, so the generic chain walk cannot find the enclosing defmodule and cbm_find_enclosing_func stops at the nearest `call` of any kind. The Elixir walk climbs the ancestors itself, and must agree byte for byte with extract_elixir_func_def's QN or the TYPE_REF and dbt edges it sources land on the file's Module node. Two exported helpers land here with no caller yet, and the resolution commit calls both: cbm_qn_container_buf (pass_calls.c, pass_usages.c and their pass_parallel.c twins) and cbm_lang_container_is_source_named (the same four sites). They are the same fence/container vocabulary as cbm_fqn_with_arity and cbm_qn_strip_arity and read as one block; splitting them across commits would put half a 30-line primitive family in each. Every other symbol this commit exports is called within it. Tests: tests/test_extraction.c pins the def QN, sibling-module distinctness, parent_class, the one-identity collapse of several clauses, the def/call-scope QN agreement, that two arities of one name do not fold, and that a nested module's clause does not fold into its parent's. Every Elixir QN assertion added earlier in this stack is rewritten to the new shape, and that rewrite is the blast radius. It depends on the by-name index commit, which carries the passed name as well as the QN's tail: with only the derived key, every fenced definition would be indexed under "fetch#3" and unreachable from a bare callee. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/cbm.c | 8 + internal/cbm/extract_defs.c | 269 +++++++++++---------- internal/cbm/extract_unified.c | 75 +++--- internal/cbm/helpers.c | 243 +++++++++++++++++++ internal/cbm/helpers.h | 82 ++++++- src/foundation/constants.h | 15 ++ src/store/store.h | 2 +- tests/repro/repro_call_argument_matrix_a.c | 21 +- tests/repro/repro_call_node_behaviors.c | 22 +- tests/test_extraction.c | 266 ++++++++++++++++---- 10 files changed, 788 insertions(+), 215 deletions(-) diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index b54fb54a7a..9447d36692 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -2744,6 +2744,14 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C if (pc == 0 && d->signature) { pc = count_params_from_signature(d->signature); } + /* Neither source is available for a language whose def head carries the + * arity structurally rather than as a named parameter list (Elixir). + * Its extractor sets param_count directly, and this pass must not + * silently zero it -- the count feeds the structural-smell metric and, + * for such a language, is the only record of arity outside the QN. */ + if (pc == 0) { + pc = d->param_count; + } d->param_count = pc; } diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 0b9cff73ca..af4ed82452 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5399,101 +5399,84 @@ static void extract_rust_impl(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec // --- Elixir def/defp/defmodule --- -// Get the "arguments" node for an Elixir call, with fallback to second child. -static TSNode elixir_call_args(TSNode node) { - TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); - if (ts_node_is_null(args) && ts_node_child_count(node) > SECOND_CHILD_IDX) { - args = ts_node_child(node, SECOND_CHILD_IDX); - } - return args; -} - // 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` / +// the previous clause. Every clause is its own `def` call, so a two-clause +// function pushes two definitions that carry the SAME (module, name, arity) QN +// -- which is right, they are one function -- and collide on one 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. // +// The QN is the fold key, so what folds is decided by cbm_elixir_def_qn: same +// container module, same name, same arity. Two arities of one name are two +// functions and do not fold, and neither do same-named functions in sibling +// modules or in a nested module. +// // 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 body block. `scope` is the block the previous clause was written in, +// so a def extracted from a different block cannot merge with a module-body +// clause of the same name and arity even when both compute the same QN +// under the same module and the two are array-adjacent. // - 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. +// candidate, so a def of another name or arity 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") 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 e783f73d and with this one -// and diffing the `nodes` rows out of the two SQLite stores: 4,342 -// Function nodes widen their span, none is added and none is lost, and -// 131 of the 4,342 now cover a module-body line that is not part of a -// clause. Counting each node once per kind, 69 cover a typespec attribute -// (@spec/@type/@typep/@opaque/@callback/@macrocallback), 53 an @doc or -// @typedoc, 15 an @impl, 59 a bare comment line, and 4 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, -// same method, 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: -// self_recursive Function nodes go 129 -> 349, and the ones carrying no -// self-call form go 24 -> 86. The lines responsible are the @spec and @doc -// heads the widened span now covers, which are read as code by the unified -// walk -- a separate defect with a separate fix, not something this fold can -// close. What the fold and the head suppression together remove is 2,303 -// edges, none of them added back: see elixir_call_is_definition_role in -// extract_calls.c. +// between two clauses: `@doc` / `@spec` are unary_operators and never reach +// the def walk at all, and `use` / `alias` / `describe` are calls that push +// no def -- so anything of that kind written between two clauses of one +// function ends up inside the widened span. Measured by +// indexing forge-symphony-graph/lib (970 .ex files) with this binary and +// with the same tree minus this fold, and diffing the `nodes` rows out of +// the two SQLite stores: 6,611 Function nodes fold, and 78 of them swallow +// a module-body-level construct that is not a def -- 69 a bare comment +// line, 5 a module-level `quote`, 4 a typespec attribute, 1 an @doc, 1 an +// @impl, and 0 a `use`, `alias`, `import` or `require`. +// The (module, name, arity) key is what keeps that number small. An +// arity-free key folds `def x/1` across the @spec of the `defp x/2` written +// between them: the same measurement against a build whose QN carries +// neither module nor arity gives 6,701 folded nodes, 162 of them swallowing +// something, and 124 of those a typespec. // -// 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. +// A clause whose macro differs (`def` z/1 beside `defp` z/1 -- a private guard +// clause written above the public head is ordinary Elixir) folds onto one node, +// so is_exported has to be the OR over the folded clauses: the name is callable +// from outside if any clause exports it, and a wholly private function stays +// unexported. That OR changes a recorded flag rather than only a span. +// On the same corpus that OR currently changes nothing: 0 of the 6,611 folded +// nodes read a different is_exported than the unfolded build does. The arity +// fence is why it is quiet -- `def x/1` beside `defp x/2` is two nodes now, so +// only a def and a defp of the SAME arity reach the OR at all. It is still +// load-bearing where they do: a last-clause-wins assignment reports a publicly +// callable name as unexported whenever the def is written above the defp, which +// is what `defp z(1)` / `def z(2)` pins in the tests. // 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)) { + TSNode block = ts_node_parent(node); + if (ts_node_is_null(block) || !ts_node_eq(block, scope)) { return false; } - CBMDefinition *prev = &ctx->result->defs.items[ctx->result->defs.count - 1]; + CBMDefinition *prev = &ctx->result->defs.items[ctx->result->defs.count - CBM_LAST_OFFSET]; 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 < + * for the immediately preceding extracted clause of the same block, and + * extract_elixir_call pushes a 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. */ @@ -5504,44 +5487,25 @@ static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, return true; } -// Handle Elixir def/defp/defmacro — extract function definition. `scope` is the -// module body the previous clause was extracted from, and is updated to this -// def's own module body; see fold_elixir_clause for what it bounds. +// Handle Elixir def/defp/defmacro — extract function definition. +// +// `module_qn` is the enclosing defmodule's QN, threaded down by the frame stack +// in extract_elixir_call. It is what gives fetch/1 in Fx.Store and fetch/1 in +// Fx.Other distinct identities, and what earns the Class -> Function +// containment edge (register_and_link_def keys on parent_class). +// +// `scope` is the body block the previous clause was extracted from, and is +// updated to this def's own block; see fold_elixir_clause for what it bounds. static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro, - TSNode *scope) { + const char *module_qn, TSNode *scope) { CBMArena *a = ctx->arena; - TSNode args = elixir_call_args(node); - if (ts_node_is_null(args)) { - return; - } - - TSNode first_arg = ts_node_child(args, 0); - if (ts_node_is_null(first_arg)) { - 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) { - name = cbm_node_text(a, ts_node_child(first_arg, 0), ctx->source); - } else if (strcmp(fk, "identifier") == 0) { - name = cbm_node_text(a, first_arg, ctx->source); - } - if (!name || !name[0]) { + int arity = 0; + const char *name = cbm_elixir_def_head(ctx, node, &arity); + if (!name) { return; } - const char *qn = cbm_fqn_compute(a, ctx->project, ctx->rel_path, name); + const char *qn = cbm_elixir_def_qn(ctx, module_qn, name, arity); 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); @@ -5555,6 +5519,8 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char memset(&def, 0, sizeof(def)); def.name = name; def.qualified_name = qn; + def.parent_class = module_qn; + def.param_count = arity; def.label = "Function"; def.file_path = ctx->rel_path; def.start_line = start_line; @@ -5563,49 +5529,99 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char cbm_defs_push(&ctx->result->defs, a, def); } -// Emit Class definition for an Elixir defmodule node. Returns do_block or null. -static TSNode emit_elixir_module_class(CBMExtractCtx *ctx, TSNode cur) { - CBMArena *a = ctx->arena; +/* Emit the Class definition for an Elixir defmodule. Writes the module's QN to + * *out_module_qn (nested under `enclosing_qn` by compute_class_qn's rule, so + * `defmodule Inner` inside `defmodule Outer` is Outer.Inner) and returns the + * do_block, or a null node when the head is not a usable defmodule. */ +static TSNode emit_elixir_module_class(CBMExtractCtx *ctx, TSNode cur, const char *enclosing_qn, + const char **out_module_qn) { TSNode null_node = {0}; - TSNode args = elixir_call_args(cur); - if (ts_node_is_null(args)) { - return null_node; - } - TSNode name_node = ts_node_child(args, 0); - if (ts_node_is_null(name_node)) { + const char *name = cbm_elixir_module_head(ctx, cur); + if (!name) { return null_node; } - char *name = cbm_node_text(a, name_node, ctx->source); - if (!name || !name[0]) { + char *module_qn = cbm_elixir_def_qn(ctx, enclosing_qn, name, CBM_ARITY_NONE); + if (!module_qn) { return null_node; } 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 = module_qn; + def.parent_class = enclosing_qn; def.label = "Class"; def.file_path = ctx->rel_path; def.start_line = ts_node_start_point(cur).row + TS_LINE_OFFSET; def.end_line = ts_node_end_point(cur).row + TS_LINE_OFFSET; def.is_exported = true; - cbm_defs_push(&ctx->result->defs, a, def); + cbm_defs_push(&ctx->result->defs, ctx->arena, def); + if (out_module_qn) { + *out_module_qn = module_qn; + } return cbm_find_child_by_kind(cur, "do_block"); } +/* A traversal frame for the Elixir walk: the node plus the defmodule QN that + * encloses it. Mirrors walk_defs_frame_t — the generic walk carries + * enclosing_class_qn the same way, and Elixir needs it for the same reason. + * Frames come from ctx->scratch, which the enclosing extract call owns. */ +typedef struct { + TSNode node; + const char *module_qn; +} elixir_frame_t; + +typedef struct { + elixir_frame_t *items; + int count; + int cap; + CBMArena *scratch; +} elixir_stack_t; + +/* Capacity multiplier when the frame stack is full. */ +enum { ELIXIR_STACK_GROWTH = 2 }; + +static void elixir_stack_init(elixir_stack_t *s, const CBMExtractCtx *ctx, int initial_cap) { + CBMArena *arena = ctx->scratch ? ctx->scratch : ctx->arena; + s->scratch = arena; + s->items = (elixir_frame_t *)cbm_arena_alloc(arena, (size_t)initial_cap * sizeof(*s->items)); + s->count = 0; + s->cap = s->items ? initial_cap : 0; +} + +static void elixir_stack_push(elixir_stack_t *s, TSNode node, const char *module_qn) { + if (s->count >= s->cap) { + int new_cap = s->cap ? s->cap * ELIXIR_STACK_GROWTH : CBM_SZ_64; + elixir_frame_t *grown = + (elixir_frame_t *)cbm_arena_alloc(s->scratch, (size_t)new_cap * sizeof(*grown)); + if (!grown) { + return; /* OOM: best-effort, stop growing */ + } + if (s->items && s->count > 0) { + memcpy(grown, s->items, (size_t)s->count * sizeof(*grown)); + } + s->items = grown; + s->cap = new_cap; + } + s->items[s->count].node = node; + s->items[s->count].module_qn = module_qn; + s->count++; +} + // Process Elixir call nodes iteratively — handles defmodule/def/defp/defmacro // 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 + /* Body block 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); + elixir_stack_t stack; + elixir_stack_init(&stack, ctx, CBM_SZ_64); + elixir_stack_push(&stack, node, ctx->enclosing_class_qn); while (stack.count > 0) { - TSNode cur = ts_nstack_pop(&stack); + elixir_frame_t frame = stack.items[--stack.count]; + TSNode cur = frame.node; CBMArena *a = ctx->arena; if (ts_node_child_count(cur) == 0) { @@ -5622,15 +5638,16 @@ 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, &def_scope); + extract_elixir_func_def(ctx, cur, macro, frame.module_qn, &def_scope); } else if (strcmp(macro, "defmodule") == 0) { - TSNode do_block = emit_elixir_module_class(ctx, cur); + const char *module_qn = NULL; + TSNode do_block = emit_elixir_module_class(ctx, cur, frame.module_qn, &module_qn); if (!ts_node_is_null(do_block)) { uint32_t dbc = ts_node_child_count(do_block); for (int di = (int)dbc - SKIP_CHAR; di >= 0; di--) { TSNode dchild = ts_node_child(do_block, (uint32_t)di); if (!ts_node_is_null(dchild) && strcmp(ts_node_type(dchild), "call") == 0) { - ts_nstack_push(&stack, dchild); + elixir_stack_push(&stack, dchild, module_qn); } } } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 9467d82e6c..f6079b80ac 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -580,44 +580,32 @@ static const char *compute_lisp_func_qn(CBMExtractCtx *ctx, TSNode node) { * `call` node whose target (first child) is the def macro and whose first * argument is either the function head call `name(args)` or a bare identifier * (zero-arg). Returns NULL for a non-def `call` (e.g. the in-body `add(x,1)` - * call, whose target is not a def macro) so only defs push a scope. Mirrors - * extract_elixir_func_def() in extract_defs.c. */ -static const char *compute_elixir_func_qn(CBMExtractCtx *ctx, TSNode node) { - if (ts_node_child_count(node) == 0) { - return NULL; - } - char *macro = cbm_node_text(ctx->arena, ts_node_child(node, 0), ctx->source); - if (!macro || (strcmp(macro, "def") != 0 && strcmp(macro, "defp") != 0 && - strcmp(macro, "defmacro") != 0)) { - return NULL; - } - TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); - if (ts_node_is_null(args) && ts_node_child_count(node) > 1) { - args = ts_node_child(node, 1); - } - if (ts_node_is_null(args) || ts_node_child_count(args) == 0) { - return NULL; - } - TSNode first_arg = ts_node_child(args, 0); - if (ts_node_is_null(first_arg)) { + * call, whose target is not a def macro) so only defs push a scope. + * + * The name, arity and QN composition come from cbm_elixir_def_head / + * cbm_elixir_def_qn, the SAME helpers extract_elixir_func_def() uses on the + * def side. They must produce a byte-identical string: calls_find_source() + * joins a call to its source node by exact QN and falls back to the File node + * on a miss, so a one-segment disagreement silently reattributes every edge + * sourced inside an Elixir function. Two hand-written copies had already + * drifted -- only the def side unwrapped the `when` guard. */ +static const char *compute_elixir_func_qn(CBMExtractCtx *ctx, TSNode node, WalkState *state) { + int arity = 0; + const char *name = cbm_elixir_def_head(ctx, node, &arity); + if (!name) { 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) { - name = cbm_node_text(ctx->arena, ts_node_child(first_arg, 0), ctx->source); - } else if (strcmp(fk, "identifier") == 0) { - name = cbm_node_text(ctx->arena, first_arg, ctx->source); - } - if (!name || !name[0]) { + return cbm_elixir_def_qn(ctx, state ? state->enclosing_class_qn : NULL, name, arity); +} + +/* The QN of an Elixir `defmodule` head, for the class scope its body runs in. + * Mirrors emit_elixir_module_class() in extract_defs.c. */ +static const char *compute_elixir_module_qn(CBMExtractCtx *ctx, TSNode node, WalkState *state) { + const char *name = cbm_elixir_module_head(ctx, node); + if (!name) { return NULL; } - return cbm_fqn_compute(ctx->arena, ctx->project, ctx->rel_path, name); + return cbm_elixir_def_qn(ctx, state ? state->enclosing_class_qn : NULL, name, CBM_ARITY_NONE); } /* Resolve a CFML tag-function's QN for scope tracking. A @@ -980,7 +968,7 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan /* Elixir: def/defp/defmacro are `call` nodes (so is every in-body call). * Gate on the def-macro target text so only definitions push a scope. */ if (ctx->language == CBM_LANG_ELIXIR) { - return compute_elixir_func_qn(ctx, node); + return compute_elixir_func_qn(ctx, node, state); } /* Objective-C: a method_definition's selector keyword is a plain `identifier` @@ -2496,7 +2484,22 @@ static void push_boundary_scopes(CBMExtractCtx *ctx, TSNode node, const CBMLangS } } } - if (!skip_nested) { + /* Elixir: `defmodule` is a `call`, and `call` is in elixir_func_types, + * so a module head reaches this branch and compute_func_qn correctly + * returns NULL for it -- but the class branch below is then never + * tried, and no module scope is ever pushed. Push it here, so a def's + * call-scope QN carries the same container segment the def node does. + * cbm_is_namespace_scope_kind cannot do this: it takes the type string + * only and every Elixir construct is a `call`. */ + bool elixir_module_scope = false; + if (!skip_nested && ctx->language == CBM_LANG_ELIXIR) { + const char *mqn = compute_elixir_module_qn(ctx, node, state); + if (mqn) { + push_lexical_scope(state, SCOPE_CLASS, depth, mqn, node, CBM_LEXICAL_SCOPE_CLASS); + elixir_module_scope = true; + } + } + if (!skip_nested && !elixir_module_scope) { const char *fqn = compute_func_qn(ctx, node, spec, state); if (fqn && push_function_scope(state, depth, fqn, node)) { const char *node_kind = ts_node_type(node); diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 6ddd8ceaf5..1e0a973054 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1271,9 +1271,56 @@ static const char *func_node_name(CBMArena *a, TSNode func_node, const char *sou return NULL; } +static const char *elixir_def_head_raw(CBMArena *a, TSNode node, const char *source, + int *out_arity); +static const char *elixir_module_head_raw(CBMArena *a, TSNode node, const char *source); + +/* Elixir has no class_node_types and every construct is a `call`, so the + * generic chain walk below cannot find the enclosing defmodule and + * cbm_find_enclosing_func would stop at the nearest `call` of any kind. Walk + * the ancestors here: the nearest def head names the function, and every + * defmodule above it contributes a container segment. Must agree byte for byte + * with extract_elixir_func_def()'s QN or the TYPE_REF/dbt edges it sources + * land on the file's Module node instead. */ +static const char *elixir_enclosing_func_qn(CBMArena *a, TSNode node, const char *source, + const char *project, const char *rel_path, + const char *module_qn) { + const char *name = NULL; + int arity = 0; + const char *container = NULL; + for (TSNode cur = node; !ts_node_is_null(cur); cur = ts_node_parent(cur)) { + if (strcmp(ts_node_type(cur), "call") != 0) { + continue; + } + if (!name) { + name = elixir_def_head_raw(a, cur, source, &arity); + if (name) { + continue; + } + } + const char *mod = elixir_module_head_raw(a, cur, source); + if (mod) { + container = container ? cbm_arena_sprintf(a, "%s.%s", mod, container) : mod; + } + } + if (!name) { + return module_qn; + } + const char *container_qn = container ? cbm_fqn_compute(a, project, rel_path, container) : NULL; + char *base = container_qn ? cbm_arena_sprintf(a, "%s.%s", container_qn, name) + : cbm_fqn_compute(a, project, rel_path, name); + if (!base) { + return module_qn; + } + return cbm_fqn_with_arity(a, base, arity); +} + const char *cbm_enclosing_func_qn(CBMArena *a, TSNode node, CBMLanguage lang, const char *source, const char *project, const char *rel_path, const char *module_qn) { + if (lang == CBM_LANG_ELIXIR) { + return elixir_enclosing_func_qn(a, node, source, project, rel_path, module_qn); + } TSNode func_node = cbm_find_enclosing_func(node, lang); if (ts_node_is_null(func_node)) { return module_qn; @@ -1723,6 +1770,202 @@ char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir) { return buf; } +/* ── QN discriminators ──────────────────────────────────────────── */ + +char *cbm_fqn_with_arity(CBMArena *a, const char *qn, int arity) { + if (!qn || arity < 0) { + return (char *)qn; + } + return cbm_arena_sprintf(a, "%s#%d", qn, arity); +} + +/* The fence, if any, is the LAST '#' followed by one or more digits and + * nothing else. Returns a pointer to that '#', or NULL. */ +static const char *qn_arity_fence(const char *qn) { + if (!qn) { + return NULL; + } + const char *hash = strrchr(qn, '#'); + if (!hash || !hash[SKIP_ONE]) { + return NULL; + } + for (const char *p = hash + SKIP_ONE; *p; p++) { + if (*p < '0' || *p > '9') { + return NULL; + } + } + return hash; +} + +size_t cbm_qn_strip_arity(char *out, size_t cap, const char *qn) { + if (!out || cap == 0) { + return 0; + } + if (!qn) { + out[0] = '\0'; + return 0; + } + const char *hash = qn_arity_fence(qn); + size_t len = hash ? (size_t)(hash - qn) : strlen(qn); + if (len > cap - SKIP_ONE) { + len = cap - SKIP_ONE; + } + memcpy(out, qn, len); + out[len] = '\0'; + return len; +} + +bool cbm_qn_container_buf(char *out, size_t cap, const char *qn) { + if (!out || cap == 0) { + return false; + } + out[0] = '\0'; + if (!qn) { + return false; + } + const char *hash = qn_arity_fence(qn); + const char *end_ptr = hash ? hash : qn + strlen(qn); + const char *dot = NULL; + for (const char *p = qn; p < end_ptr; p++) { + if (*p == '.') { + dot = p; + } + } + if (!dot || dot == qn) { + return false; + } + size_t len = (size_t)(dot - qn); + if (len > cap - SKIP_ONE) { + return false; + } + memcpy(out, qn, len); + out[len] = '\0'; + return true; +} + +/* ── Elixir def-head resolution ─────────────────────────────────── */ + +/* Position of a call's `arguments` node when the grammar names no field: the + * head is child 0, its argument list child 1. */ +enum { ELIXIR_ARGS_CHILD_IDX = 1 }; + +/* The `arguments` node of an Elixir `call`, by field or by position. */ +static TSNode elixir_args_of(TSNode node) { + TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); + if (ts_node_is_null(args) && ts_node_child_count(node) > ELIXIR_ARGS_CHILD_IDX) { + args = ts_node_child(node, ELIXIR_ARGS_CHILD_IDX); + } + return args; +} + +/* The text of a call's head (its first child), or NULL. */ +static char *elixir_call_head_text(CBMArena *a, TSNode node, const char *source) { + if (ts_node_child_count(node) == 0) { + return NULL; + } + TSNode head = ts_node_child(node, 0); + if (ts_node_is_null(head)) { + return NULL; + } + return cbm_node_text(a, head, source); +} + +static const char *elixir_def_head_raw(CBMArena *a, TSNode node, const char *source, + int *out_arity) { + if (out_arity) { + *out_arity = 0; + } + char *macro = elixir_call_head_text(a, node, source); + if (!macro || (strcmp(macro, "def") != 0 && strcmp(macro, "defp") != 0 && + strcmp(macro, "defmacro") != 0 && strcmp(macro, "defmacrop") != 0)) { + return NULL; + } + TSNode args = elixir_args_of(node); + if (ts_node_is_null(args) || ts_node_child_count(args) == 0) { + return NULL; + } + TSNode first_arg = ts_node_child(args, 0); + if (ts_node_is_null(first_arg)) { + return NULL; + } + /* `def name(args) when guard` parses the whole head as a `when` + * binary_operator, so the name lives on its left operand. Without this + * unwrap every guarded clause is dropped, and a function whose clauses ALL + * carry guards never appears in the graph -- silently, since a missing + * definition is not an error. The unwrap is shared with the calls and + * usages walks, which read this same argument to tell a head from an + * invocation and to bound the head's bindings; a private copy in one of + * them makes the rest disagree about what a guarded clause is. It peels + * only `when`, so an operator definition (`def a + b`) is left alone rather + * than named after its 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) { + name = cbm_node_text(a, ts_node_child(first_arg, 0), source); + if (out_arity) { + TSNode head_args = elixir_args_of(first_arg); + *out_arity = ts_node_is_null(head_args) ? 0 : (int)ts_node_named_child_count(head_args); + } + } else if (strcmp(fk, "identifier") == 0) { + /* `def foo do` / `def foo, do: ...` -- a bare identifier head is arity 0. */ + name = cbm_node_text(a, first_arg, source); + } + if (!name || !name[0]) { + return NULL; + } + return name; +} + +const char *cbm_elixir_def_head(CBMExtractCtx *ctx, TSNode node, int *out_arity) { + return elixir_def_head_raw(ctx->arena, node, ctx->source, out_arity); +} + +static const char *elixir_module_head_raw(CBMArena *a, TSNode node, const char *source) { + char *macro = elixir_call_head_text(a, node, source); + if (!macro || strcmp(macro, "defmodule") != 0) { + return NULL; + } + TSNode args = elixir_args_of(node); + if (ts_node_is_null(args)) { + return NULL; + } + TSNode name_node = ts_node_child(args, 0); + if (ts_node_is_null(name_node)) { + return NULL; + } + char *name = cbm_node_text(a, name_node, source); + if (!name || !name[0]) { + return NULL; + } + return name; +} + +const char *cbm_elixir_module_head(CBMExtractCtx *ctx, TSNode node) { + return elixir_module_head_raw(ctx->arena, node, ctx->source); +} + +char *cbm_elixir_def_qn(CBMExtractCtx *ctx, const char *container_qn, const char *name, int arity) { + CBMArena *a = ctx->arena; + char *base = container_qn ? cbm_arena_sprintf(a, "%s.%s", container_qn, name) + : cbm_fqn_compute(a, ctx->project, ctx->rel_path, name); + if (!base || arity < 0 || !cbm_lang_overloads_by_arity(ctx->language)) { + return base; + } + return cbm_fqn_with_arity(a, base, arity); +} + +/* ── Language traits ────────────────────────────────────────────── */ + +bool cbm_lang_container_is_source_named(CBMLanguage lang) { + return lang == CBM_LANG_ELIXIR; +} + +bool cbm_lang_overloads_by_arity(CBMLanguage lang) { + return lang == CBM_LANG_ELIXIR; +} + /* ── String literal classifier ──────────────────────────────────── */ // Check if a slash-prefixed string looks like a filesystem path. diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index c18f27429b..ecc0d02b33 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -109,10 +109,11 @@ const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, c // 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. +// it (through cbm_elixir_def_head), the unified walk opens the function's call +// scope from it (same helper), 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 @@ -250,6 +251,79 @@ char *cbm_fqn_compute_source_lang(CBMArena *a, const char *project, const char * // Folder QN: project.dir_parts char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir); +/* --- QN discriminators (container segment + arity fence) --------------- + * + * A qualified name is a '.'-separated path whose LAST segment is the symbol + * name. '.' is structurally reserved as that separator across the whole + * codebase (simple_name, qualified_suffix_match, the LIKE '%.'||suffix store + * probe, ~90 strrchr(qn,'.') sites), so a second discriminator can never be a + * dot segment. The fence character is '#', following the precedent already set + * by rust_cfg_qualified_name() in extract_defs.c ("add#cfg(test)"). + * + * An arity-fenced QN reads "proj.file.Module.name#N". Everything that wants the + * symbol's dotted identity strips the fence first. */ + +/* "#". Returns `qn` unchanged when arity < 0. */ +char *cbm_fqn_with_arity(CBMArena *a, const char *qn, int arity); + +/* Copy `qn` into `out` without its '#' fence. Returns the length + * written (truncated to cap-1 on overflow). `out` is always NUL-terminated. + * A QN with no fence copies verbatim. */ +size_t cbm_qn_strip_arity(char *out, size_t cap, const char *qn); + +/* The container of a QN: everything before its final '.' segment, with any + * arity fence removed first. Writes it into `out` and returns true; returns + * false and leaves `out` empty when the QN has no '.'. Buffer-only, because + * every caller is pipeline-side and holds no arena. */ +bool cbm_qn_container_buf(char *out, size_t cap, const char *qn); + +/* --- Language traits (enum-keyed, NOT CBMLangSpec fields) --------------- + * + * These are predicates rather than spec rows for a build reason recorded at + * lang_specs.h:47: cbm_string_dispatch_suffixes was kept out of CBMLangSpec to + * avoid -Wmissing-field-initializers across the positional language rows, and + * Makefile.cbm compiles with -Wall -Wextra -Werror. */ + +/* True when a language names its container in the SOURCE rather than deriving + * it from the file path — an Elixir `defmodule Fx.Store` owns its defs no + * matter which file holds it, so the container segment belongs in the def QN. + * Path-derived containers (Python module, Go package) already ride in the QN + * through cbm_fqn_compute and must not be doubled. */ +bool cbm_lang_container_is_source_named(CBMLanguage lang); + +/* True when a language distinguishes two definitions of the same name in the + * same container by parameter count — Elixir's fetch/1 and fetch/3 are + * different functions, not clauses of one. */ +bool cbm_lang_overloads_by_arity(CBMLanguage lang); + +/* --- Elixir def-head resolution (one implementation, two consumers) ----- + * + * Elixir models every construct as a `call` node, so a def, a defmodule and an + * ordinary in-body call are the same kind. Both the definition extractor + * (extract_defs.c) and the call-scope walker (extract_unified.c) must derive + * the SAME name, the same arity and the same container from such a node, or + * the def QN and the enclosing_func_qn on its body's calls disagree and every + * CALLS/USAGE edge sourced inside that function silently reattributes to the + * file's Module node. They used to be two hand-written copies, and had already + * drifted: only the def side unwrapped the `when` guard binary_operator. + * + * `node` is the outer def `call`. Returns the function name and writes the + * arity (0 for a bare `def foo do`), or NULL when the node is not a + * def/defp/defmacro. A default-argument head (`def f(a, b \\ 1)`) reports its + * MAXIMUM arity: one node per written head, not one per reachable arity. */ +const char *cbm_elixir_def_head(CBMExtractCtx *ctx, TSNode node, int *out_arity); + +/* The module name written by an Elixir `defmodule Name do`, or NULL when the + * node is not a defmodule. The name is source text and may itself be dotted + * ("Fx.Store"), which is exactly what belongs in the QN. */ +const char *cbm_elixir_module_head(CBMExtractCtx *ctx, TSNode node); + +/* Compose an Elixir definition QN: `container` + "." + `name`, falling back to + * the path-derived QN when there is no container, then fenced with `arity` + * where the language trait admits it. Shared so the def side and the call + * scope side cannot drift. Pass arity < 0 for a container (defmodule) QN. */ +char *cbm_elixir_def_qn(CBMExtractCtx *ctx, const char *container_qn, const char *name, int arity); + /* Flatten a JS/TS `template_string` node into plain text: string fragments are * kept verbatim and each ${...} substitution becomes the "{}" placeholder, so * client-side URLs built from template literals share the canonical parameter diff --git a/src/foundation/constants.h b/src/foundation/constants.h index 9af0d4001c..4581f25549 100644 --- a/src/foundation/constants.h +++ b/src/foundation/constants.h @@ -68,6 +68,12 @@ enum { CBM_INIT_DONE = 1, /* initialization flag */ }; +/* ── Arity fence ─────────────────────────────────────────────── */ +/* A qualified name may carry an arity fence ("Mod.fetch#3"). CBM_ARITY_NONE is + * the sentinel for a QN that carries none and for a call site that offers no + * usable argument count; a real arity is always >= 0. */ +enum { CBM_ARITY_NONE = -1 }; + /* ── Default pagination limits ───────────────────────────────── */ /* Default page size for search_graph and the underlying store-layer search. * Responses land in an LLM agent's context window, so the default favors a @@ -94,6 +100,15 @@ enum { /* Common offset constants (used across many files). */ enum { SKIP_ONE = 1, PAIR_LEN = 2 }; +/* Indexing from a count, not offsetting from a pointer. SKIP_ONE above answers + * "how far do I advance past this element" (p + SKIP_ONE, tail[-SKIP_ONE]); + * this one answers "which index is the last one". Both are 1, so nothing + * misbehaves when they are swapped -- which is exactly why the name has to + * carry the intent. readability-magic-numbers forbids the bare literal, so the + * constant chosen is the only record of the question being asked: + * `items[n - CBM_LAST_OFFSET]` reads "the last element of n". */ +enum { CBM_LAST_OFFSET = 1 }; + /* ── Label allowlists for SQL ──────────────────────────────────── * SQL mirror of cbm_label_is_type_like() (internal/cbm/helpers.c). That * function is documented as the single source of truth for type-like labels diff --git a/src/store/store.h b/src/store/store.h index b6ce844f75..e79f00a5ce 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -23,7 +23,7 @@ typedef struct cbm_store cbm_store_t; #define CBM_STORE_OK 0 #define CBM_STORE_ERR (-1) #define CBM_STORE_NOT_FOUND (-2) -#define CBM_INDEX_FORMAT_VERSION 1 +#define CBM_INDEX_FORMAT_VERSION 2 #define CBM_STORE_CANCELLED (-3) #define CBM_STORE_SCAN_LIMIT (-4) #define CBM_STORE_CALLBACK_ERR (-5) diff --git a/tests/repro/repro_call_argument_matrix_a.c b/tests/repro/repro_call_argument_matrix_a.c index 5399bddab4..3eb9941a16 100644 --- a/tests/repro/repro_call_argument_matrix_a.c +++ b/tests/repro/repro_call_argument_matrix_a.c @@ -76,7 +76,26 @@ static int qn_has_terminal_name(const char *qn, const char *name) { return 0; size_t qn_len = strlen(qn); size_t name_len = strlen(name); - if (name_len > qn_len || strcmp(qn + qn_len - name_len, name) != 0) + /* A QN may end in an arity fence ("...Sample.argument#1"), which is a + * discriminator rather than part of the symbol's dotted identity. Drop it + * before the terminal-name comparison: the question here is which function + * a call was attributed to, and two arities of one name are still that + * name. A fence is '#' followed by digits only, so "Foo#Bar" is untouched. */ + if (qn_len > 0) { + const char *hash = strrchr(qn, '#'); + if (hash && hash[1]) { + int digits = 1; + for (const char *p = hash + 1; *p; p++) { + if (*p < '0' || *p > '9') { + digits = 0; + break; + } + } + if (digits) + qn_len = (size_t)(hash - qn); + } + } + if (name_len > qn_len || strncmp(qn + qn_len - name_len, name, name_len) != 0) return 0; if (name_len == qn_len) return 1; diff --git a/tests/repro/repro_call_node_behaviors.c b/tests/repro/repro_call_node_behaviors.c index 2a007edca3..2c8695b344 100644 --- a/tests/repro/repro_call_node_behaviors.c +++ b/tests/repro/repro_call_node_behaviors.c @@ -39,11 +39,31 @@ static int terminal_name_matches(const char *raw, const char *expected) { return 1; size_t raw_len = strlen(raw); + /* A QN may end in an arity fence ("...Sample.run#1"), a discriminator + * rather than part of the symbol's dotted identity. Drop it before the + * terminal-name comparison: the question here is which routine a call or + * definition belongs to, and two arities of one name are still that name. + * Only '#' followed by digits counts, so "Foo#Bar" is untouched. */ + const char *fence = strrchr(raw, '#'); + if (fence && fence[1]) { + int digits = 1; + for (const char *p = fence + 1; *p; p++) { + if (*p < '0' || *p > '9') { + digits = 0; + break; + } + } + if (digits) { + raw_len = (size_t)(fence - raw); + if (raw_len == strlen(expected) && strncmp(raw, expected, raw_len) == 0) + return 1; + } + } size_t expected_len = strlen(expected); if (raw_len < expected_len) return 0; const char *tail = raw + raw_len - expected_len; - if (strcmp(tail, expected) != 0) + if (strncmp(tail, expected, expected_len) != 0) return 0; if (tail == raw) return 1; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index a969f15c67..8665d1c905 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1498,15 +1498,17 @@ 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. */ +/* The other half of the guarded-head defect: extracting the def is not enough, + * because the guard also hides the head from the calls walk and from the scope + * walk. Unrecognised as a definition, the head was emitted as a real invocation + * -- a phantom CALLS edge naming the function being defined -- and by two + * different routes. `def f(a) when g` reaches the calls walk as the inner + * `f(a)` call, which no longer compares equal to the def's first argument; + * `def f when g` has no inner call at all, so the guard operator itself falls + * through to the generic first-identifier fallback and mints the phantom from + * the bare name. Opening no function scope, every call in a guarded body was + * attributed to the FILE node, so the function reported no outgoing edges and + * its callees gained an in-edge from the file instead of from their caller. */ TEST(extract_elixir_guarded_def_head_scope) { CBMFileResult *r = extract("defmodule Guards do\n" " def target(x), do: x\n" @@ -1522,41 +1524,46 @@ TEST(extract_elixir_guarded_def_head_scope) { ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); - /* A paren-less guarded head is a definition, never a call to itself. */ + /* A guarded head is a definition, never a call to itself -- in either shape. */ + ASSERT_EQ(count_calls_named(r, "guarded_caller"), 0); 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); + * paren-less clause's body call to that clause. The scope QN carries the + * module and the arity, exactly as the def QN does; a disagreement of one + * segment would leave these at 0 and the file-node assertions below at 1. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.Guards.guarded_caller#1"), 1); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope.Guards.guarded_caller#1"), 1); + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.Guards.bare_guarded#0"), 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); + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.Guards.unguarded_caller#1"), 1); - /* Nothing falls back to the file node. */ + /* Nothing falls back to the file node or stops at the module scope. */ ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope"), 0); ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope"), 0); + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.Guards"), 0); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope.Guards"), 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. */ +/* The third half of the guarded-head defect, and the one the calls and defs + * 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" @@ -1569,35 +1576,35 @@ TEST(extract_elixir_guarded_def_head_guard_usages) { 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); + ASSERT_EQ(count_usages_from(r, "a", "t.guard_usage.GuardUsage.guarded#2"), 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_from(r, "b", "t.guard_usage.GuardUsage.guarded#2"), 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_from(r, "x", "t.guard_usage.GuardUsage.plain#1"), 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 - * 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. */ +/* Every clause of an Elixir function is its own `def` call, so a multi-clause + * function pushes one definition per clause, all carrying the same + * (module, name, arity) QN and colliding on one 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?(%{})... 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 + * body block, 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 */ @@ -1647,17 +1654,47 @@ TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module) { /* 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_STR_EQ("t.roles.Roles.admin?#1", admin[0]->qualified_name); + ASSERT_STR_EQ("t.roles.Guests.admin?#1", admin[2]->qualified_name); 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. */ +/* Two arities of one name are two functions, not clauses of one, and the QN is + * the fold key -- so a widened span must never reach across them. `get/1` and + * `get/2` written adjacently keep their own spans and their own is_exported. */ +TEST(extract_elixir_clauses_of_different_arity_do_not_fold) { + CBMFileResult *r = extract("defmodule Fetcher do\n" /* 1 */ + " def get(k), do: get(k, nil)\n" /* 2 */ + " defp get(k, d), do: {k, d}\n" /* 3 */ + " defp get(k, d, _o), do: {k, d}\n" /* 4 */ + "end\n", /* 5 */ + CBM_LANG_ELIXIR, "t", "fetcher.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(3, count_defs_named(r, "Function", "get")); + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->label, "Function") != 0 || strcmp(d->name, "get") != 0) { + continue; + } + /* Each keeps its own single line: nothing folded. */ + ASSERT_EQ((int)d->start_line, (int)d->end_line); + ASSERT_EQ(d->param_count == 1, d->is_exported); + } + ASSERT(has_def_qn(r, "t.fetcher.Fetcher.get#1")); + ASSERT(has_def_qn(r, "t.fetcher.Fetcher.get#2")); + ASSERT(has_def_qn(r, "t.fetcher.Fetcher.get#3")); + cbm_free_result(r); + PASS(); +} + +/* A nested module used to be the one place the "def of another name ends the + * group" rule was not enough, because the QN carried no module. It carries one + * now, so `Outer.run` and `Outer.Inner.run` are different keys and would not + * fold on that ground alone -- this pins that they do not. */ TEST(extract_elixir_nested_module_clauses_do_not_fold) { CBMFileResult *r = extract("defmodule Outer do\n" /* 1 */ " defmodule Inner do\n" /* 2 */ @@ -1670,6 +1707,8 @@ TEST(extract_elixir_nested_module_clauses_do_not_fold) { ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); ASSERT_EQ(2, count_defs_named(r, "Function", "run")); + ASSERT(has_def_qn(r, "t.nested.Outer.Inner.run#1")); + ASSERT(has_def_qn(r, "t.nested.Outer.run#1")); const CBMDefinition *runs[2] = {NULL, NULL}; int seen = 0; @@ -2040,6 +2079,135 @@ TEST(elixir_call_string_argument) { PASS(); } +/* Node identity for Elixir used to be (file path, bare name): no container + * segment and no arity, so fetch/1, fetch/2 and fetch/3 in one module were ONE + * node holding whichever head sorted last, and three `def message/1` across + * three exception modules in one file were one node too. The three existing + * Elixir cases above all assert through has_def(), which compares def->name + * only, so they would pass unchanged across a complete QN rewrite -- these + * assert the QN itself. */ +TEST(elixir_def_qn_carries_module_and_arity) { + CBMFileResult *r = extract("defmodule Fx.Store do\n" + " def fetch(a), do: a\n" + " def fetch(a, b), do: {a, b}\n" + " def fetch(a, b, c), do: {a, b, c}\n" + " def bare, do: :ok\n" + "end\n", + CBM_LANG_ELIXIR, "t", "lib/store.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def_qn(r, "t.lib.store.Fx.Store.fetch#1")); + ASSERT(has_def_qn(r, "t.lib.store.Fx.Store.fetch#2")); + ASSERT(has_def_qn(r, "t.lib.store.Fx.Store.fetch#3")); + /* A bare `def name, do:` head takes no arguments. */ + ASSERT(has_def_qn(r, "t.lib.store.Fx.Store.bare#0")); + ASSERT_EQ(3, count_defs_named(r, "Function", "fetch")); + /* The name column stays the bare symbol: it is what search_graph's + * name_pattern matches and what agents are told to pass. */ + ASSERT(has_def(r, "Function", "fetch")); + cbm_free_result(r); + PASS(); +} + +TEST(elixir_same_named_defs_in_sibling_modules_are_distinct) { + CBMFileResult *r = extract("defmodule Fx.ErrA do\n" + " def message(e), do: e\n" + "end\n" + "\n" + "defmodule Fx.ErrB do\n" + " def message(e), do: e\n" + "end\n" + "\n" + "defmodule Fx.ErrC do\n" + " def message(e), do: e\n" + "end\n", + CBM_LANG_ELIXIR, "t", "lib/errors.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def_qn(r, "t.lib.errors.Fx.ErrA.message#1")); + ASSERT(has_def_qn(r, "t.lib.errors.Fx.ErrB.message#1")); + ASSERT(has_def_qn(r, "t.lib.errors.Fx.ErrC.message#1")); + ASSERT_EQ(3, count_defs_named(r, "Function", "message")); + cbm_free_result(r); + PASS(); +} + +/* A def carries its module as parent_class, which is what earns the + * Class -> Function containment edge in the definitions pass. Without it + * module membership existed only as a QN prefix and no edge expressed it. */ +TEST(elixir_def_records_parent_module) { + CBMFileResult *r = extract("defmodule Fx.Store do\n" + " def fetch(a), do: a\n" + "end\n", + CBM_LANG_ELIXIR, "t", "lib/store.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + int seen = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") != 0) { + continue; + } + seen = 1; + ASSERT_NOT_NULL(r->defs.items[i].parent_class); + ASSERT_STR_EQ("t.lib.store.Fx.Store", r->defs.items[i].parent_class); + ASSERT_EQ(1, r->defs.items[i].param_count); + } + ASSERT_EQ(1, seen); + cbm_free_result(r); + PASS(); +} + +/* Several clauses of ONE function are one definition, not one per clause. + * The (container, name, arity) key keeps that collapse and only fixes the + * key -- un-collapsing clauses would be a different defect. */ +TEST(elixir_multi_clause_function_stays_one_identity) { + CBMFileResult *r = extract("defmodule Fx.Norm do\n" + " def norm(nil), do: nil\n" + " def norm(x) when is_binary(x), do: x\n" + " def norm(x), do: to_string(x)\n" + "end\n", + CBM_LANG_ELIXIR, "t", "lib/norm.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") == 0) { + ASSERT_STR_EQ("t.lib.norm.Fx.Norm.norm#1", r->defs.items[i].qualified_name); + } + } + cbm_free_result(r); + PASS(); +} + +/* The def side and the call-scope side are separate code paths that must emit + * a byte-identical string: a call is joined to its source node by exact QN and + * falls back to the FILE node on a miss, so a one-segment disagreement + * silently reattributes every edge sourced inside an Elixir function. They had + * already drifted once -- only the def side unwrapped the `when` guard. */ +TEST(elixir_call_scope_qn_matches_def_qn) { + CBMFileResult *r = extract("defmodule Fx.Store do\n" + " def helper(x), do: x\n" + " def guarded(x) when is_binary(x) do\n" + " helper(x)\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "lib/store.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def_qn(r, "t.lib.store.Fx.Store.guarded#1")); + int seen = 0; + for (int i = 0; i < r->calls.count; i++) { + if (strcmp(r->calls.items[i].callee_name, "helper") != 0) { + continue; + } + seen = 1; + ASSERT_NOT_NULL(r->calls.items[i].enclosing_func_qn); + ASSERT_STR_EQ("t.lib.store.Fx.Store.guarded#1", r->calls.items[i].enclosing_func_qn); + } + ASSERT_EQ(1, seen); + cbm_free_result(r); + PASS(); +} + /* --- Haskell --- */ TEST(haskell_function) { CBMFileResult *r = extract("add :: Int -> Int -> Int\nadd x y = x + y\n\nmultiply :: Int -> " @@ -9157,12 +9325,18 @@ SUITE(extraction) { 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_clauses_of_different_arity_do_not_fold); 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(extract_elixir_typespec_attribute_is_not_code); + RUN_TEST(elixir_def_qn_carries_module_and_arity); + RUN_TEST(elixir_same_named_defs_in_sibling_modules_are_distinct); + RUN_TEST(elixir_def_records_parent_module); + RUN_TEST(elixir_multi_clause_function_stays_one_identity); + RUN_TEST(elixir_call_scope_qn_matches_def_qn); RUN_TEST(elixir_call_string_argument); RUN_TEST(extract_elixir_guarded_def_head_scope); RUN_TEST(extract_elixir_guarded_def_head_guard_usages); From 52882af5598dffbe6f46678d10bd7e79057df5d9 Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:23 -0700 Subject: [PATCH 08/13] fix(elixir): extract definitions inside macro and conditional blocks The def walk pushed only the direct `call` children of a defmodule's do_block and dropped every other head without descending. A def written inside `if Mix.env() == :test do`, `quote`, `case`, `for` or a user macro (all ordinary Elixir) produced no node and no error. elixir_push_block_calls descends through body-block kinds (do/else/rescue/catch/after/block/stab_clause/body) under the same module, bounded by ELIXIR_BLOCK_DESCENT_MAX, so pathological nesting returns at the bound instead of overflowing the C stack. `defmacrop` joins the three macros that already minted a Function. It has to join them in three lists at once: the def walk, the calls walk's definition-role list, and the usages walk's binding list. By contract those three are the same list, and a head one of them declines is relabelled. handle_calls declining a node leaves state->callee_expr unset, and handle_usages then reaches the bare identifier and mints a USAGE onto the same function. Under UNIQUE(source, target, type) that is a separate row, so it no longer collides with the real call it used to hide behind. Before this commit the def walk minted a node for `defmacrop` and the calls walk did not know its head, so a private macro sourced a phantom CALLS edge onto itself from its own def line and cbm.c read it as self-recursion by line containment. Measured by building a product binary from the module/arity identity commit and one from this commit, indexing the same 970-file Elixir lib tree with each into its own sandbox (`cli index_repository --mode full`), and diffing the Function `qualified_name` sets out of the two SQLite stores. Both stores report 970 File nodes: Function nodes 22,226 -> 22,259 new qualified names 33 lost qualified names 0 Nothing is lost: block descent reaches definitions that had no node at all and does not move any definition that already had one. The block descent also completes the fold's "same body block" bound, which was previously unreachable: a def inside a `quote` body computes the same (module, name, arity) QN as a module-body clause of that name and is extracted immediately after it, so only the block check keeps a quoted template out of the real function's span. extract_elixir_quoted_clause_does_not_fold_into_a_sibling pins it and could not have been written before this commit, because the quoted def was not extracted at all. The calls-walk comment was split across two functions and is now in one: the macro list is stated there as a cross-file contract, with the paren-less route beside it and no longer repeated at the definition-container check. recursion_whitelist.h gains elixir_push_block_calls_d and names its bound. Tests: elixir_defs_inside_macro_blocks_are_extracted covers `if`, `quote`, `case` and a user macro body; extract_elixir_private_macro_head_is_not_a_self_call pins the defmacrop node, its privacy, that it is not recursive, and that exactly one reference of any kind to it exists in the file (the real invocation); extract_elixir_quoted_clause_does_not_fold_into_a_sibling pins the block bound. Depends on the module/arity identity commit, whose frame stack carries the module QN this descent threads through the nested blocks. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/extract_calls.c | 105 +++++++++++++-------------- internal/cbm/extract_defs.c | 68 ++++++++++++++--- internal/cbm/extract_usages.c | 7 +- src/foundation/recursion_whitelist.h | 5 +- tests/test_extraction.c | 103 ++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 66 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 0335258925..363a2e1cf2 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -708,52 +708,54 @@ 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 +/* A definition's head declares a name; it is never a call to it. Deciding that + * takes agreement across three files, because each of them reads the SAME first + * argument of the def and a disagreement does not fail, it mints an edge: + * - extract_defs.c names the definition from it, through cbm_elixir_def_head. + * If this file disagrees, the head becomes a CALLS edge onto the function + * from its own def line, which cbm.c then reads as self-recursion because + * that line sits inside the function's own span. + * - extract_unified.c opens the function's call scope from it, through the + * same helper, so a def this file recognises and that walk does not would + * source the body's calls to the FILE node instead of to the function. + * - 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. + * The def-macro list below and the one in is_elixir_def_binding must therefore + * stay the same list, and the head itself comes from the shared unwrap + * (cbm_elixir_def_head_is) rather than from a private copy here. * - * 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`. + * A guarded head reaches this walk by two different routes and both have to be + * recognised. `def f(a) when g` arrives as the inner `f(a)` call, which is not + * the def's first argument once a guard wraps it; `def f when g` has no inner + * call at all, so the `when` operator itself reaches extract_callee_name and, + * having no callee of its own, takes that function's last resort -- the first + * identifier child -- minting the same phantom from the bare name. 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 pre-existing behaviour for those operators and is untouched here. * - * Measured by indexing one 970-file Elixir lib tree with the binary built from - * e783f73d and with this one and diffing the `edges` rows out of the two - * SQLite stores, keyed on (source QN, target QN, type): 2,303 edges go and - * NONE is added -- 1,990 CALLS, 312 USAGE, and one CONFIGURES minted from - * `defp tls_unconfigured(%{tls_port: port, trust_anchor: anchor})`, whose head - * was read as a configuration call. Of the 116,591 surviving edges, 777 record - * a different `line` and none changes its source, target or type. + * Measured over forge-symphony-graph/lib (970 .ex files), counting references + * whose recorded callee IS the name being declared and whose recorded line is + * that declaration's own line: a build with none of this records 2,758 of them, + * 1,797 on a guarded head; this build records 48, of which 1 is guarded -- and + * all 48 are genuine same-line delegations to another arity + * (`defp parse_model({p, t} = m), do: parse_model(p, t)`), not phantoms. + * Downstream that is the whole of the self-recursion defect: of 20,961 Function + * nodes, 1,308 are flagged self_recursive before and 1,202 of those have no + * self-call form anywhere in their span (92% of the flag false); after, 420 are + * flagged and 8 are false (2%), and no true positive is lost -- every + * (file, name) pair flagged before that does have a self-call form is still + * flagged. Node identity alone does not get there: with the (module, name, + * arity) key but without this suppression the flag reads 1,476 with 1,224 false + * (83%). * * 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 + * -- put a 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 @@ -762,10 +764,17 @@ static bool elixir_call_head_in(TSNode call, const char *source, const char *con * 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. */ + * skipping the whole subtree in the unified walk + * (is_elixir_typespec_attribute, extract_unified.c). */ 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}; + /* The same four function-defining macros cbm_elixir_def_head() mints a + * Function for. A head this list omits is not recognised as a declaration, + * so it is emitted as an invocation of the very name being defined -- and + * because the def side DOES mint a node for it, that phantom lands inside + * the function's own span and cbm.c reads it as self-recursion. */ + static const char *const structural_heads[] = {"def", "defp", "defmacro", + "defmacrop", "defmodule", NULL}; + static const char *const function_heads[] = {"def", "defp", "defmacro", "defmacrop", NULL}; if (elixir_call_head_in(node, source, structural_heads)) { return true; } @@ -804,19 +813,9 @@ static bool call_node_is_definition_container(CBMLanguage lang, TSNode node, con } /* 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. */ + * and must be able to answer that it is a definition. Only a `when` operator + * is admitted: an operator definition's own head (`def a + b`) stays an + * ordinary node here, 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_defs.c b/internal/cbm/extract_defs.c index af4ed82452..e294b4a4b0 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5418,9 +5418,10 @@ static void extract_rust_impl(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec // Two bounds keep the widened span honest, and a clause folds only if it clears // both: // - Same body block. `scope` is the block the previous clause was written in, -// so a def extracted from a different block cannot merge with a module-body -// clause of the same name and arity even when both compute the same QN -// under the same module and the two are array-adjacent. +// so a def the walk reaches inside a `quote`, `if` or `case` body cannot +// merge with a module-body clause of the same name and arity: both compute +// the same QN under the same module, and the quoted def is array-adjacent +// to the real one. // - Same group. Only the immediately preceding EXTRACTED definition is a // candidate, so a def of another name or arity between two same-named defs // ends the group. That is a bound this code imposes, not a guarantee Elixir @@ -5607,6 +5608,52 @@ static void elixir_stack_push(elixir_stack_t *s, TSNode node, const char *module s->count++; } +/* True for a block kind that holds ordinary Elixir body expressions. `do_block` + * covers `do ... end`; the grammar hangs `else`/`rescue`/`catch`/`after` arms + * off it as their own block nodes, and a `->` arm is a `stab_clause` whose + * right-hand side is a `body`. */ +static bool elixir_is_body_block(const char *kind) { + return strcmp(kind, "do_block") == 0 || strcmp(kind, "else_block") == 0 || + strcmp(kind, "rescue_block") == 0 || strcmp(kind, "catch_block") == 0 || + strcmp(kind, "after_block") == 0 || strcmp(kind, "block") == 0 || + strcmp(kind, "stab_clause") == 0 || strcmp(kind, "body") == 0; +} + +/* Push every `call` reachable through `node`'s body blocks, under `module_qn`. + * Blocks nest (a do_block holds an else_block, a stab_clause holds a body), so + * this descends through block kinds and stops at the first non-block child. + * + * This is what makes a def inside `if Mix.env() == :test do`, `quote`, `case` + * or `for` reachable at all: the walk used to push only the direct `call` + * children of a defmodule's do_block and drop every other head WITHOUT + * descending, so conditionally compiled definitions -- ordinary Elixir -- + * produced no node and no error. */ +#define ELIXIR_BLOCK_DESCENT_MAX 64 + +static void elixir_push_block_calls_d(elixir_stack_t *stack, // NOLINT(misc-no-recursion) + TSNode node, const char *module_qn, int depth) { + if (depth > ELIXIR_BLOCK_DESCENT_MAX) { + return; /* pathological block nesting: bounded, not a C-stack overflow */ + } + uint32_t cc = ts_node_child_count(node); + for (int i = (int)cc - SKIP_CHAR; i >= 0; i--) { + TSNode child = ts_node_child(node, (uint32_t)i); + if (ts_node_is_null(child)) { + continue; + } + const char *ck = ts_node_type(child); + if (strcmp(ck, "call") == 0) { + elixir_stack_push(stack, child, module_qn); + } else if (elixir_is_body_block(ck)) { + elixir_push_block_calls_d(stack, child, module_qn, depth + SKIP_ONE); + } + } +} + +static void elixir_push_block_calls(elixir_stack_t *stack, TSNode node, const char *module_qn) { + elixir_push_block_calls_d(stack, node, module_qn, 0); +} + // Process Elixir call nodes iteratively — handles defmodule/def/defp/defmacro // without recursion between extract_elixir_call ↔ extract_elixir_module_def. static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { @@ -5637,20 +5684,19 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp } if (strcmp(macro, "def") == 0 || strcmp(macro, "defp") == 0 || - strcmp(macro, "defmacro") == 0) { + strcmp(macro, "defmacro") == 0 || strcmp(macro, "defmacrop") == 0) { extract_elixir_func_def(ctx, cur, macro, frame.module_qn, &def_scope); } else if (strcmp(macro, "defmodule") == 0) { const char *module_qn = NULL; TSNode do_block = emit_elixir_module_class(ctx, cur, frame.module_qn, &module_qn); if (!ts_node_is_null(do_block)) { - uint32_t dbc = ts_node_child_count(do_block); - for (int di = (int)dbc - SKIP_CHAR; di >= 0; di--) { - TSNode dchild = ts_node_child(do_block, (uint32_t)di); - if (!ts_node_is_null(dchild) && strcmp(ts_node_type(dchild), "call") == 0) { - elixir_stack_push(&stack, dchild, module_qn); - } - } + elixir_push_block_calls(&stack, do_block, module_qn); } + } else { + /* Any other macro head (if/unless/case/cond/for/with/try/quote, + * defimpl/defprotocol, a user macro) can still hold definitions in + * its body. Descend through its blocks under the SAME module. */ + elixir_push_block_calls(&stack, cur, frame.module_qn); } } } diff --git a/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index 57594c665c..0d747196f4 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -606,8 +606,13 @@ static bool is_elixir_def_binding(CBMExtractCtx *ctx, TSNode node) { continue; } TSNode head = ts_node_named_child(form, 0); + /* Must list the same four macros the calls walk suppresses a head for. + * A head declined there but not treated as a binding here does not lose + * its phantom, it is relabelled: handle_usages reaches the bare + * identifier handle_calls just declined and mints a USAGE onto the same + * function. */ if (!text_equals(ctx, head, "def") && !text_equals(ctx, head, "defp") && - !text_equals(ctx, head, "defmacro")) { + !text_equals(ctx, head, "defmacro") && !text_equals(ctx, head, "defmacrop")) { continue; } TSNode arguments = ts_node_child_by_field_name(form, TS_FIELD("arguments")); diff --git a/src/foundation/recursion_whitelist.h b/src/foundation/recursion_whitelist.h index c3e2b5c8c6..0b27909435 100644 --- a/src/foundation/recursion_whitelist.h +++ b/src/foundation/recursion_whitelist.h @@ -22,6 +22,9 @@ * - find_first_descendant_by_kind (Verilog/SystemVerilog name wrappers) * - find_first_descendant_of (Dart/Zig import URI/string nesting) * + * Elixir body-block descent (bounded by ELIXIR_BLOCK_DESCENT_MAX): + * - elixir_push_block_calls_d + * * To add a function: add it below AND add NOLINT(misc-no-recursion) on * the function definition line. The lint gate verifies both match. */ @@ -30,4 +33,4 @@ "parse_post_where", "cbm_parse", "eval_expr", "glob_match", "glob_match_star", \ "glob_match_doublestar", "glob_match_doublestar_slash", "glob_match_doublestar_any", \ "parse_bool_expr", "parse_bool_atom", "r_collect_imports", \ - "find_first_descendant_by_kind", "find_first_descendant_of" + "find_first_descendant_by_kind", "find_first_descendant_of", "elixir_push_block_calls_d" diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 8665d1c905..a9ca3b0959 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1728,6 +1728,35 @@ TEST(extract_elixir_nested_module_clauses_do_not_fold) { PASS(); } +/* The block bound on the fold, which the QN cannot supply. A def written inside + * a `quote` body belongs to the same module and computes the SAME + * (module, name, arity) QN as a module-body clause of that name, and the walk + * extracts it immediately after -- so only "same body block" keeps a quoted + * template out of the real function's span. */ +TEST(extract_elixir_quoted_clause_does_not_fold_into_a_sibling) { + CBMFileResult *r = extract("defmodule Tmpl do\n" /* 1 */ + " def x(a), do: a\n" /* 2 */ + " quote do\n" /* 3 */ + " def x(b), do: b\n" /* 4 */ + " end\n" /* 5 */ + "end\n", /* 6 */ + CBM_LANG_ELIXIR, "t", "tmpl.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(2, count_defs_named(r, "Function", "x")); + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->label, "Function") != 0 || strcmp(d->name, "x") != 0) { + continue; + } + /* Same QN, different blocks: neither span swallowed the other. */ + ASSERT_STR_EQ("t.tmpl.Tmpl.x#1", d->qualified_name); + ASSERT_EQ((int)d->start_line, (int)d->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 @@ -1885,6 +1914,44 @@ static int elixir_any_ref_count(CBMFileResult *r, const char *name) { return count; } +/* The four macros that mint a Function have to be the same four in every walk. + * The def walk minted one for `defmacrop`; the calls walk did not know the head, + * so it emitted it as an invocation of the macro being defined, and that phantom + * lands inside the macro's own span -- a private macro reporting itself + * recursive. A def head is only ever removed, never relabelled, when the usages + * walk treats the same four as bindings, so both lists are pinned here. */ +TEST(extract_elixir_private_macro_head_is_not_a_self_call) { + CBMFileResult *r = extract("defmodule Wrap do\n" /* 1 */ + " defmacrop source_call(do: expr) do\n" /* 2 */ + " quote do: unquote(expr)\n" /* 3 */ + " end\n" /* 4 */ + "\n" /* 5 */ + " def run(x) do\n" /* 6 */ + " source_call do: x\n" /* 7 */ + " end\n" /* 8 */ + "end\n", /* 9 */ + CBM_LANG_ELIXIR, "t", "wrap.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* The macro is a definition and a private one. */ + ASSERT_EQ(1, count_defs_named(r, "Function", "source_call")); + ASSERT(has_def_qn(r, "t.wrap.Wrap.source_call#1")); + const CBMDefinition *mac = elixir_first_def(r, "source_call"); + ASSERT_NOT_NULL(mac); + ASSERT_FALSE(mac->is_exported); + ASSERT_FALSE(mac->is_recursive); + ASSERT_EQ(2, (int)mac->start_line); + + /* Exactly one reference to it exists in this file -- the invocation in + * `run` -- and it is a CALL from `run`, not a relabelled usage and not a + * second one minted by the macro's own head. */ + ASSERT_EQ(1, count_calls_named(r, "source_call")); + ASSERT_EQ(1, count_calls_from(r, "source_call", "t.wrap.Wrap.run#1")); + ASSERT_EQ(1, elixir_any_ref_count(r, "source_call")); + cbm_free_result(r); + PASS(); +} + /* 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 @@ -2157,6 +2224,39 @@ TEST(elixir_def_records_parent_module) { PASS(); } +/* The walk used to push only the DIRECT `call` children of a defmodule's + * do_block and drop every other head without descending, so a def inside + * `if Mix.env() == :test do`, `quote`, `case` or `for` produced no node at + * all -- silently, since a missing definition is not an error. Conditional + * compilation is ordinary Elixir: on one real 3-module file this hid 42 of + * 48 def heads. */ +TEST(elixir_defs_inside_macro_blocks_are_extracted) { + CBMFileResult *r = extract("defmodule Nest do\n" + " def top(x), do: x\n" + " if true do\n" + " def inside_if(x), do: x\n" + " else\n" + " def inside_else(x), do: x\n" + " end\n" + " case :a do\n" + " :a -> def inside_case(x), do: x\n" + " end\n" + " quote do\n" + " def inside_quote(x), do: x\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "lib/nest.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def_qn(r, "t.lib.nest.Nest.top#1")); + ASSERT(has_def_qn(r, "t.lib.nest.Nest.inside_if#1")); + ASSERT(has_def_qn(r, "t.lib.nest.Nest.inside_else#1")); + ASSERT(has_def_qn(r, "t.lib.nest.Nest.inside_case#1")); + ASSERT(has_def_qn(r, "t.lib.nest.Nest.inside_quote#1")); + cbm_free_result(r); + PASS(); +} + /* Several clauses of ONE function are one definition, not one per clause. * The (container, name, arity) key keeps that collapse and only fixes the * key -- un-collapsing clauses would be a different defect. */ @@ -9327,14 +9427,17 @@ SUITE(extraction) { RUN_TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module); RUN_TEST(extract_elixir_clauses_of_different_arity_do_not_fold); RUN_TEST(extract_elixir_nested_module_clauses_do_not_fold); + RUN_TEST(extract_elixir_quoted_clause_does_not_fold_into_a_sibling); 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_private_macro_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_def_qn_carries_module_and_arity); RUN_TEST(elixir_same_named_defs_in_sibling_modules_are_distinct); RUN_TEST(elixir_def_records_parent_module); + RUN_TEST(elixir_defs_inside_macro_blocks_are_extracted); RUN_TEST(elixir_multi_clause_function_stays_one_identity); RUN_TEST(elixir_call_scope_qn_matches_def_qn); RUN_TEST(elixir_call_string_argument); From 8ca7db0988413310d1d9d82fcfb1fe85515459ce Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:23 -0700 Subject: [PATCH 09/13] fix(pipeline): emit DEFINES_METHOD edges for Function members pass_definitions and its parallel twin gated the Class -> member edge on `label == "Method"`, so a language whose members are labelled Function had no containment edge at all, and module membership existed only as a QN prefix. Previous commits in this stack made an Elixir def name its container, and the graph still had no edge for "what does this module contain" to follow. Relabelling Elixir defs to Method would put a falsehood in a column that the FTS rank boost, cbm_label_is_registry_symbol, cbm_label_is_type_like and the search label filter all read. Widening the gate is the smaller blast radius: every other site in extract_defs.c that sets parent_class on a callable already labels it Method, so the only edges this adds are for Function members. Measured by indexing one 970-file Elixir lib tree with the binary built from the previous commit in this stack and with this one, and diffing the `edges` rows out of the two SQLite stores: DEFINES_METHOD edges 0 -> 22,258 every other edge type unchanged 22,258 of the 22,259 Function nodes gain a containment edge; the one that does not is a def written outside any defmodule. Both copies of the gate move together. pass_definitions.c runs the sequential path and register_and_link_def in pass_parallel.c the threaded one; a change to one and not the other is a full-vs-incremental divergence that no single-mode test can see. Elixir's container is labelled Class, and a module is not a class. Fixing that label is deliberately out of scope here: it means deciding what the FTS rank boost and the type-like predicate should do with a new one. tests/test_pipeline.c: pipeline_elixir_container_and_arity_identity indexes a three-file Elixir repo end to end and asserts the DEFINES_METHOD edge from `Fx.Store` to `fetch` and from `Fx.ErrB` to `message`, alongside the identity assertions from the previous commit (three arities, three nodes; three same-named defs in three modules, three nodes). The resolution commit later in this stack extends the same test with its two CALLS assertions. Its content dependency is the module/arity identity commit, which sets parent_class on an Elixir def. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- src/pipeline/pass_definitions.c | 15 ++++++- src/pipeline/pass_parallel.c | 15 ++++++- tests/test_pipeline.c | 71 +++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 7c7ed68847..af87c2b255 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -343,7 +343,20 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const cbm_gbuf_insert_edge(ctx->gbuf, file_node->id, node_id, "DEFINES", "{}"); } free(file_qn); - if (def->parent_class && def->label && strcmp(def->label, "Method") == 0) { + /* Containment: a definition that names its container earns a + * Class -> member edge. The gate was label == "Method" alone, which is + * why a language whose members are labelled Function (Elixir's defs) had + * NO containment edge at all -- module membership existed only as a QN + * prefix. Relabelling those to Method would put a falsehood in the label + * column to satisfy an edge condition; label is read by the FTS rank + * boost, cbm_label_is_registry_symbol, cbm_label_is_type_like and the + * search label filter. Widening the gate is the smaller blast radius: + * every other site in extract_defs.c that sets parent_class on a callable + * already labels it Method, so this adds edges for Function members only. + * (Elixir's container is labelled Class, which is wrong -- a module is not + * a class -- and is out of scope here.) */ + if (def->parent_class && def->label && + (strcmp(def->label, "Method") == 0 || strcmp(def->label, "Function") == 0)) { const cbm_gbuf_node_t *parent = cbm_gbuf_find_by_qn(ctx->gbuf, def->parent_class); if (parent && node_id > 0) { cbm_gbuf_insert_edge(ctx->gbuf, parent->id, node_id, "DEFINES_METHOD", "{}"); diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f1..067e0f1f35 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1596,7 +1596,20 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d cbm_gbuf_insert_edge(ctx->gbuf, file_node_id, def_node->id, "DEFINES", "{}"); edges++; } - if (def->parent_class && strcmp(def->label, "Method") == 0) { + /* Containment: a definition that names its container earns a + * Class -> member edge. The gate was label == "Method" alone, which is + * why a language whose members are labelled Function (Elixir's defs) had + * NO containment edge at all -- module membership existed only as a QN + * prefix. Relabelling those to Method would put a falsehood in the label + * column to satisfy an edge condition; label is read by the FTS rank + * boost, cbm_label_is_registry_symbol, cbm_label_is_type_like and the + * search label filter. Widening the gate is the smaller blast radius: + * every other site in extract_defs.c that sets parent_class on a callable + * already labels it Method, so this adds edges for Function members only. + * (Elixir's container is labelled Class, which is wrong -- a module is not + * a class -- and is out of scope here.) */ + if (def->parent_class && + (strcmp(def->label, "Method") == 0 || strcmp(def->label, "Function") == 0)) { const cbm_gbuf_node_t *parent = cbm_gbuf_find_by_qn(ctx->gbuf, def->parent_class); if (parent && def_node) { cbm_gbuf_insert_edge(ctx->gbuf, parent->id, def_node->id, "DEFINES_METHOD", "{}"); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 1243564700..e210691193 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15107,6 +15107,76 @@ TEST(pipeline_objectscript_export_range_join_keeps_one_trailing_marker) { } #endif +/* End-to-end identity + resolution guard for a source-named container. + * Everything below is a measured defect the graph used to carry: + * - fetch/1, fetch/2 and fetch/3 in one module collapsed to ONE node; + * - three `def message/1` in three modules in one file collapsed to one; + * - no Class -> Function containment edge existed at all. */ +TEST(pipeline_elixir_container_and_arity_identity) { + char *repo = th_mktempdir("cbm_elixir_identity"); + ASSERT_NOT_NULL(repo); + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s", repo); + + ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "lib/probe.ex"), + "defmodule Fx.Probe do\n" + " def get(n), do: n\n" + "end\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "lib/store.ex"), + "defmodule Fx.Store do\n" + " def fetch(a), do: a\n" + " def fetch(a, b), do: {a, b}\n" + " def fetch(a, b, c), do: {a, b, c}\n" + " def run(opts, r) do\n" + " {Keyword.get(opts, :reason), Fx.Probe.get(r), fetch(1)}\n" + " end\n" + "end\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "lib/errors.ex"), + "defmodule Fx.ErrA do\n" + " def message(e), do: e\n" + "end\n" + "defmodule Fx.ErrB do\n" + " def message(e), do: e\n" + "end\n" + "defmodule Fx.ErrC do\n" + " def message(e), do: e\n" + "end\n"), + 0); + + char db_path[600]; + snprintf(db_path, sizeof(db_path), "%s/elixir_identity.db", tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *st = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(st); + + /* Three arities, three nodes. Three modules, three `message` nodes. */ + cbm_node_t *fetches = NULL; + int fetch_count = 0; + cbm_store_find_nodes_by_name(st, project, "fetch", &fetches, &fetch_count); + ASSERT_EQ(fetch_count, 3); + cbm_store_free_nodes(fetches, fetch_count); + + cbm_node_t *messages = NULL; + int message_count = 0; + cbm_store_find_nodes_by_name(st, project, "message", &messages, &message_count); + ASSERT_EQ(message_count, 3); + cbm_store_free_nodes(messages, message_count); + + /* Module membership is an edge, not just a QN prefix. */ + ASSERT_TRUE(cross_file_edge_exists(st, project, "Fx.Store", "fetch", "DEFINES_METHOD")); + ASSERT_TRUE(cross_file_edge_exists(st, project, "Fx.ErrB", "message", "DEFINES_METHOD")); + + cbm_store_close(st); + cbm_pipeline_free(p); + PASS(); +} + SUITE(pipeline) { RUN_TEST(pipeline_lsp_surface_persisted_and_body_edit_invariant); /* Index lock */ @@ -15154,6 +15224,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_nix_scoped_binding_calls_resolve); RUN_TEST(pipeline_hcl_block_reference_resolves_to_its_block); RUN_TEST(pipeline_incremental_preserves_cross_file_calls); + RUN_TEST(pipeline_elixir_container_and_arity_identity); RUN_TEST(pipeline_objectscript_export_preserves_calls_sequential_parallel); RUN_TEST(pipeline_objectscript_export_incremental_matches_full_relationships); RUN_TEST(pipeline_objectscript_export_aggregate_exceeds_arena_block_table); From 77bb2f5f377ae648d97fe0338da66ce98804302e Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:23 -0700 Subject: [PATCH 10/13] feat(pipeline): resolve calls with caller container and written arity cbm_registry_resolve takes a bare (callee, module) pair and throws away two pieces of evidence the call site actually holds: the CALLER's own container, and the number of arguments written. cbm_registry_resolve_ctx takes both, and both fields are optional. A NULL context leaves the two new strategies inert: the container probe needs a container, the arity choice needs a written arity, and a caller that passes NULL has neither. The commit also extends an EXISTING rule to one more strategy, and that extension is unconditional. receiver_chain_admits is already in registry.c on e783f73d and already guards both name-lookup strategies for every language. It did not guard the same-module suffix fallback, which composes its candidate from the caller's module and the callee's SUFFIX and throws the written qualifier away. So `Keyword.get` in a project that defines its own `get` bound to that `get`, at same-module confidence, by a route the guard never saw. This commit routes that fallback through the same predicate. It is not reached through the context argument and does not read one, so a NULL context does NOT reproduce cbm_registry_resolve byte for byte: it differs exactly where a capitalised qualified callee used to bind to the caller's own module, which is the `Foo.bar` shape Java, C#, Python, Ruby and Swift callees take. The difference is deliberate. The fallback discards the qualifier in every language, so the defect is not Elixir's, and gating the guard on the new context would fix one language and leave the other 161 as they were, with the project's own rule already applied two strategies away. For those languages the guard can only withhold a candidate the fallback was about to return. `module_qn` is the FILE's QN, which is the container only for a language whose container IS the path. Where a language names its container in the source, the def QN carries an extra segment a file-QN probe can never match, so an intra-module call fell through to the bare-name scorer at lower confidence. A green test cannot see that degradation, because the scorer often still lands on a right-looking answer. Two arities of one name look like two unrelated candidates to every scorer in resolve_name_lookup, and letting import distance choose between them picks by path proximity among functions that are not interchangeable. When the call site wrote N arguments and exactly one candidate is fenced at N, that IS the answer. Arity is used only to choose among candidates that differ by their fence, never to reject a candidate that carries none. `has_arity_fence` keeps the whole mechanism off for a corpus that has none: no extra bucket scan on the hot same-module path, and no wider cache key. The per-file resolve cache key grows only where a container is present (one file holds several source-named containers, so the callee name alone is no longer sufficient) or where a fence exists. qualified_suffix_match strips a fence before comparing tails, because the fence is a discriminator and no part of the dotted identity the callee text names. Without that, every fenced definition was invisible to the one strategy built to disambiguate a qualified callee. It now counts matches rather than bailing on the second, so an arity hint can choose among them. Measured by building a product binary from the containment commit and one from this commit, indexing the same 970-file Elixir lib tree with each into its own sandbox (`cli index_repository --mode full`), and counting rows in the two SQLite stores. Both stores report 970 File nodes, which pins the corpus: CALLS edges 54,191 -> 55,266 CALLS edges sourced from a Function 53,441 -> 54,502 The +1,075 CALLS are calls the container-aware strategy resolves that a file-QN same-module probe could not see. They are a recovery, not a net gain over pristine: the module/arity identity commit declines a bare callee it can no longer attribute to one arity, and the stack ends at 55,266 against e783f73d's 55,812. The receiver-chain guard's cost on this corpus is inside that net. No row here isolates it, because the corpus is Elixir and every one of this commit's mechanisms is live on it at once. Both resolve paths change together. pass_calls.c and pass_usages.c run the sequential path, resolve_file_calls and resolve_file_usages in pass_parallel.c the threaded one; the container recovery is duplicated verbatim across the twins because a divergence there is a full-vs-incremental difference no single-mode test can catch. tests/test_registry.c: resolve_qualified_suffix_sees_through_arity_fence pins that a qualified callee reaches a fenced candidate and that an arity no candidate carries declines rather than guessing; resolve_same_module_uses_caller_container pins the container path and that the file QN alone still cannot compose the candidate; resolve_qualified_is_monotone_under_unrelated_additions pins that adding one unrelated same-named symbol does not re-point an already-resolved edge; resolve_foreign_receiver_does_not_bind_local_name pins that `Keyword.get` resolves to nothing rather than to the project's own get/1; resolve_receiver_guard_is_language_agnostic pins the same refusal on unfenced Java-, Python- and JavaScript-shaped QNs through the NULL-context entry point, so the unconditional half has a test that owes nothing to Elixir, and pins the two carve-outs (a lower-case root and an ALL_CAPS constant root still bind). tests/test_pipeline.c: pipeline_elixir_container_and_arity_identity gains its two CALLS assertions, end to end over a three-file repo. Its shared end-to-end test lives with the containment commit. It also depends on the module/arity identity commit, for the fence. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- src/foundation/constants.h | 13 +- src/pipeline/pass_calls.c | 23 ++- src/pipeline/pass_parallel.c | 43 +++++- src/pipeline/pass_usages.c | 27 +++- src/pipeline/pipeline.h | 24 +++ src/pipeline/registry.c | 289 ++++++++++++++++++++++++++++++----- tests/test_pipeline.c | 8 +- tests/test_registry.c | 160 +++++++++++++++++++ 8 files changed, 530 insertions(+), 57 deletions(-) diff --git a/src/foundation/constants.h b/src/foundation/constants.h index 4581f25549..2711d15116 100644 --- a/src/foundation/constants.h +++ b/src/foundation/constants.h @@ -102,12 +102,13 @@ enum { SKIP_ONE = 1, PAIR_LEN = 2 }; /* Indexing from a count, not offsetting from a pointer. SKIP_ONE above answers * "how far do I advance past this element" (p + SKIP_ONE, tail[-SKIP_ONE]); - * this one answers "which index is the last one". Both are 1, so nothing - * misbehaves when they are swapped -- which is exactly why the name has to - * carry the intent. readability-magic-numbers forbids the bare literal, so the - * constant chosen is the only record of the question being asked: - * `items[n - CBM_LAST_OFFSET]` reads "the last element of n". */ -enum { CBM_LAST_OFFSET = 1 }; + * these answer "which index is the last one" and "how many are there". All + * three are 1, so nothing misbehaves when they are swapped -- which is exactly + * why the name has to carry the intent. readability-magic-numbers forbids the + * bare literal, so the constant chosen is the only record of the question + * being asked: `items[n - CBM_LAST_OFFSET]` reads "the last element of n", + * `n == CBM_COUNT_ONE` reads "exactly one candidate". */ +enum { CBM_LAST_OFFSET = 1, CBM_COUNT_ONE = 1 }; /* ── Label allowlists for SQL ──────────────────────────────────── * SQL mirror of cbm_label_is_type_like() (internal/cbm/helpers.c). That diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 84958954cf..8a59e545ce 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -26,6 +26,7 @@ enum { PC_RING = 4, PC_RING_MASK = 3, PC_SIG_SCAN = 15, PC_REGEX_GRP = 2 }; #include "foundation/limits.h" #include "foundation/str_util.h" #include "cbm.h" +#include "helpers.h" #include "service_patterns.h" #include "foundation/compat_regex.h" @@ -544,8 +545,26 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, } } - cbm_resolution_t res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, - imp_keys, imp_vals, imp_count); + /* The caller's own container, where the language names it in the source + * rather than deriving it from the path. Elixir's fetch/1 lives in + * `proj.file.Fx.Store`, not `proj.file`, so the same-module strategy needs + * this to find an intra-module call at all. NULL everywhere else, which + * leaves resolution byte-identical for every other language. */ + char container_buf[CBM_SZ_512]; + const char *container_qn = NULL; + if (cbm_lang_container_is_source_named(lang) && call->enclosing_func_qn && + (!module_qn || strcmp(call->enclosing_func_qn, module_qn) != 0) && + cbm_qn_container_buf(container_buf, sizeof(container_buf), call->enclosing_func_qn)) { + container_qn = container_buf; + } + /* arg_count saturates at CBM_MAX_CALL_ARGS and skips splats, so it is the + * written arity only below the cap and with no splat present. Above it the + * hint is withheld rather than guessed. */ + cbm_resolve_ctx_t rx = {.container_qn = container_qn, + .arity = call->arg_count < CBM_MAX_CALL_ARGS ? call->arg_count + : CBM_ARITY_NONE}; + cbm_resolution_t res = cbm_registry_resolve_ctx(ctx->registry, call->callee_name, module_qn, + &rx, imp_keys, imp_vals, imp_count); if (!res.qualified_name || res.qualified_name[0] == '\0') { /* Resolution is empty when the callee belongs to an EXTERNAL client * library whose source is not in the indexed tree (e.g. `requests.get`, diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 067e0f1f35..2744505f71 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2909,8 +2909,29 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * exact LSP target and must fail closed rather than accepting a textual * registry match. */ if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution) { - res = cbm_registry_resolve(rc->registry, call->callee_name, module_qn, imp_keys, - imp_vals, imp_count); + /* The caller's own container, where the language names it in the + * source rather than deriving it from the path. Elixir's fetch/1 + * lives in `proj.file.Fx.Store`, not `proj.file`, so the + * same-module strategy needs this to find an intra-module call at + * all. NULL everywhere else, which leaves resolution + * byte-identical for every other language. Must mirror the + * sequential twin (pass_calls.c) exactly. */ + char container_buf[CBM_SZ_512]; + const char *container_qn = NULL; + if (cbm_lang_container_is_source_named(lang) && call->enclosing_func_qn && + (!module_qn || strcmp(call->enclosing_func_qn, module_qn) != 0) && + cbm_qn_container_buf(container_buf, sizeof(container_buf), + call->enclosing_func_qn)) { + container_qn = container_buf; + } + /* arg_count saturates at CBM_MAX_CALL_ARGS and skips splats, so it + * is the written arity only below the cap. Above it the hint is + * withheld rather than guessed. */ + cbm_resolve_ctx_t rx = {.container_qn = container_qn, + .arity = call->arg_count < CBM_MAX_CALL_ARGS ? call->arg_count + : CBM_ARITY_NONE}; + res = cbm_registry_resolve_ctx(rc->registry, call->callee_name, module_qn, &rx, + imp_keys, imp_vals, imp_count); } atomic_fetch_add_explicit(&rc->time_ns_rc_resolve, extract_now_ns() - _rc_t0, memory_order_relaxed); @@ -3127,12 +3148,26 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, * resolves through the default variant, whose central relation * veto keeps same-named code identifiers out of the lineage layer. * Must mirror the sequential twin (pass_usages.c) exactly. */ + /* The caller's own container, where the language names it in the + * source rather than deriving it from the path (Elixir + * defmodule). NULL for every other language. */ + char container_buf[CBM_SZ_512]; + const char *container_qn = NULL; + if (cbm_lang_container_is_source_named(lang) && usage->enclosing_func_qn && + (!module_qn || strcmp(usage->enclosing_func_qn, module_qn) != 0) && + cbm_qn_container_buf(container_buf, sizeof(container_buf), + usage->enclosing_func_qn)) { + container_qn = container_buf; + } + /* A usage is a reference, not an invocation: no argument list, so + * no arity to offer. */ + cbm_resolve_ctx_t rx = {.container_qn = container_qn, .arity = CBM_ARITY_NONE}; cbm_resolution_t res = (lang == CBM_LANG_SQL) ? cbm_registry_resolve_lineage(rc->registry, usage->ref_name, module_qn, imp_keys, imp_vals, imp_count) - : cbm_registry_resolve(rc->registry, usage->ref_name, module_qn, imp_keys, - imp_vals, imp_count); + : cbm_registry_resolve_ctx(rc->registry, usage->ref_name, module_qn, &rx, + imp_keys, imp_vals, imp_count); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 9738f7198d..c677cf65da 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -24,6 +24,7 @@ #include "foundation/compat_fs.h" #include "foundation/limits.h" #include "cbm.h" +#include "helpers.h" #include #include @@ -192,16 +193,32 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res if (semantic_reference) { continue; } - /* SQL usages are FROM/JOIN lineage refs and may bind Table/View + /* The caller's own container, where the language names it in the + * source rather than deriving it from the path (Elixir defmodule). + * NULL for every other language, leaving resolution unchanged + * there. Must mirror the parallel twin (pass_parallel.c) exactly. */ + char container_buf[CBM_SZ_512]; + const char *container_qn = NULL; + if (cbm_lang_container_is_source_named(lang) && usage->enclosing_func_qn && + (!module_qn || strcmp(usage->enclosing_func_qn, module_qn) != 0) && + cbm_qn_container_buf(container_buf, sizeof(container_buf), + usage->enclosing_func_qn)) { + container_qn = container_buf; + } + /* A usage is a reference, not an invocation: no argument list, so + * no arity to offer. + * + * SQL usages are FROM/JOIN lineage refs and may bind Table/View * targets (cbm_registry_resolve_lineage); every other language - * resolves through the default variant, whose central relation - * veto keeps same-named code identifiers out of the lineage layer. */ + * resolves through the default variant, whose central relation veto + * keeps same-named code identifiers out of the lineage layer. */ + cbm_resolve_ctx_t rx = {.container_qn = container_qn, .arity = CBM_ARITY_NONE}; cbm_resolution_t res = (lang == CBM_LANG_SQL) ? cbm_registry_resolve_lineage(ctx->registry, usage->ref_name, module_qn, imp_keys, imp_vals, imp_count) - : cbm_registry_resolve(ctx->registry, usage->ref_name, module_qn, imp_keys, - imp_vals, imp_count); + : cbm_registry_resolve_ctx(ctx->registry, usage->ref_name, module_qn, &rx, + imp_keys, imp_vals, imp_count); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index e5a672cb0b..9def7dd6d1 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -236,6 +236,30 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count); +/* Extra evidence a call site holds that a bare (callee, module) pair does not. + * Both fields are optional: `{.container_qn = NULL, .arity = CBM_ARITY_NONE}`, + * or a NULL pointer in place of the struct, reproduces cbm_registry_resolve + * exactly. A zeroed struct does not -- its arity is 0, which is a real arity + * (a nullary call), not the no-evidence sentinel. */ +typedef struct { + /* The CALLER's own container. For a language whose container is named in + * the source (an Elixir defmodule) the file QN is NOT the container, so + * the same-module strategy cannot find an intra-container call without it. + * NULL for every path-container language. */ + const char *container_qn; + /* The number of arguments written at the call site, or CBM_ARITY_NONE when + * unknown or unreliable (splats, or more than CBM_MAX_CALL_ARGS). Used only + * to choose among candidates that differ by their QN's arity fence; never to + * reject a candidate that carries no fence. */ + int arity; +} cbm_resolve_ctx_t; + +/* Resolve with call-site evidence. `rx` may be NULL. */ +cbm_resolution_t cbm_registry_resolve_ctx(const cbm_registry_t *r, const char *callee_name, + const char *module_qn, const cbm_resolve_ctx_t *rx, + const char **import_map_keys, + const char **import_map_vals, int import_map_count); + /* Relation-permitting resolve for SQL FROM/JOIN lineage usages ONLY — the one * consumer allowed to bind Table/View targets. Uncached (the per-file resolve * cache stores the default variant's relation-vetoed answers). */ diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 42346515cb..d722ee9775 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -96,10 +96,34 @@ struct cbm_registry { /* byName: simpleName → qn_array_t* (heap-owned) */ CBMHashTable *by_name; + + /* True once any registered QN carries an arity fence. Every fence-aware + * probe below is skipped while this is false, so a corpus with no + * overload-by-arity language pays nothing for the mechanism -- neither the + * extra bucket scan on the hot same-module path nor the wider cache key. */ + bool has_arity_fence; }; /* ── Helpers ─────────────────────────────────────────────────────── */ +/* A QN's arity fence ("...fetch#3" -> 3), or CBM_ARITY_NONE when it carries + * none. Only an all-digit tail counts, so a Rust cfg twin minted as + * "add#cfg(test)" reports CBM_ARITY_NONE. The parse lives here rather than in + * the extraction layer's helpers because the registry is a pipeline TU and does + * not pull in the extraction header. */ +static int qn_fence_arity(const char *qn) { + const char *hash = strrchr(qn, '#'); + if (!hash || !hash[SKIP_ONE]) { + return CBM_ARITY_NONE; + } + for (const char *p = hash + SKIP_ONE; *p; p++) { + if (*p < '0' || *p > '9') { + return CBM_ARITY_NONE; + } + } + return (int)strtol(hash + SKIP_ONE, NULL, CBM_DECIMAL_BASE); +} + /* Extract the last path segment from a QN. Returns pointer into s. * Recognizes both '.' (most langs) and Rust/C++ '::' separators, so a * scoped callee like "lib::square" yields "square" rather than the whole @@ -922,6 +946,10 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified cbm_ht_set(r->exact, strdup(qualified_name), (void *)interned); const char *owned_qn = cbm_ht_get_key(r->exact, qualified_name); + if (!r->has_arity_fence && qn_fence_arity(owned_qn) >= 0) { + r->has_arity_fence = true; + } + /* Index the symbol under the name its caller passed AND under the QN's * last dot segment, whenever the two differ. * @@ -1073,20 +1101,107 @@ static cbm_resolution_t resolve_import_map(const cbm_registry_t *r, const char * return empty_result(); } -/* Strategy 2: Same-module match */ -static cbm_resolution_t resolve_same_module(const cbm_registry_t *r, const char *callee_name, - const char *suffix, const char *module_qn) { +static bool receiver_chain_admits(const char *callee_name, const char *candidate_qn); + +/* True when `qn` is `stem` followed by nothing but an arity fence. */ +static bool qn_is_fenced_form_of(const char *qn, const char *stem, size_t stem_len) { + if (strncmp(qn, stem, stem_len) != 0 || qn[stem_len] != '#') { + return false; + } + return qn_fence_arity(qn) >= 0; +} + +/* One same-module probe: does `base`.`tail` name a registered symbol? + * + * An arity-fenced definition is stored as "base.tail#N", so the plain exact + * lookup misses it and the call would fall through to the bare-name scorer at + * roughly half the confidence -- a silent degradation, since the scorer often + * still lands on a right-looking answer. The fenced forms of the same stem are + * therefore searched too: `arity` picks among them when the call site knows + * how many arguments it wrote, and a single fenced form wins unaided. */ +static cbm_resolution_t same_module_probe(const cbm_registry_t *r, const char *base, + const char *tail, int arity) { char candidate[CBM_SZ_512]; - snprintf(candidate, sizeof(candidate), "%s.%s", module_qn, callee_name); + int written = snprintf(candidate, sizeof(candidate), "%s.%s", base, tail); + if (written < 0 || (size_t)written >= sizeof(candidate)) { + return empty_result(); + } const char *stored_key = cbm_ht_get_key(r->exact, candidate); if (stored_key) { return (cbm_resolution_t){stored_key, "same_module", CONF_SAME_MODULE, REG_RESOLVED}; } + if (!r->has_arity_fence) { + return empty_result(); /* nothing in this graph can carry a fence */ + } + if (arity >= 0) { + char fenced[CBM_SZ_512]; + int fw = snprintf(fenced, sizeof(fenced), "%s#%d", candidate, arity); + if (fw >= 0 && (size_t)fw < sizeof(fenced)) { + stored_key = cbm_ht_get_key(r->exact, fenced); + if (stored_key) { + return (cbm_resolution_t){stored_key, "same_module", CONF_SAME_MODULE, + REG_RESOLVED}; + } + } + } + /* No arity hint, or none of that arity: accept a SOLE fenced form. */ + qn_array_t *arr = cbm_ht_get(r->by_name, simple_name(tail)); + if (!arr) { + return empty_result(); + } + const char *match = NULL; + for (int i = 0; i < arr->count; i++) { + if (!qn_is_fenced_form_of(arr->items[i], candidate, (size_t)written)) { + continue; + } + if (match) { + return empty_result(); /* several arities and no hint to choose */ + } + match = arr->items[i]; + } + if (match) { + return (cbm_resolution_t){match, "same_module", CONF_SAME_MODULE, REG_RESOLVED}; + } + return empty_result(); +} + +/* Strategy 2: Same-module match. + * + * `module_qn` is the FILE's QN, which is the container only for languages whose + * container IS the path. Where a language names its container in the source + * (an Elixir defmodule), the def QN carries that extra segment and a file-QN + * probe can never match it, so an intra-module call would fall through to the + * bare-name scorer at a LOWER confidence -- a silent degradation a green test + * cannot see. `container_qn` is the caller's own container, recovered from its + * enclosing_func_qn, and is tried first. It is NULL for every caller that has + * none, leaving the file-QN behaviour exactly as it was. */ +static cbm_resolution_t resolve_same_module(const cbm_registry_t *r, const char *callee_name, + const char *suffix, const char *module_qn, + const char *container_qn, int arity) { + cbm_resolution_t res; + if (container_qn && container_qn[0]) { + res = same_module_probe(r, container_qn, callee_name, arity); + if (res.qualified_name) { + return res; + } + } + res = same_module_probe(r, module_qn, callee_name, arity); + if (res.qualified_name) { + return res; + } if (suffix && suffix[0]) { - snprintf(candidate, sizeof(candidate), "%s.%s", module_qn, suffix); - stored_key = cbm_ht_get_key(r->exact, candidate); - if (stored_key) { - return (cbm_resolution_t){stored_key, "same_module", CONF_SAME_MODULE, REG_RESOLVED}; + /* Suffix fallback: the callee's qualifier is DISCARDED here, so a + * qualified `Other.run` could otherwise bind to this module's own run. + * Guard it with the same receiver-chain rule the name lookup uses. */ + if (container_qn && container_qn[0]) { + res = same_module_probe(r, container_qn, suffix, arity); + if (res.qualified_name && receiver_chain_admits(callee_name, res.qualified_name)) { + return res; + } + } + res = same_module_probe(r, module_qn, suffix, arity); + if (res.qualified_name && receiver_chain_admits(callee_name, res.qualified_name)) { + return res; } } return empty_result(); @@ -1135,6 +1250,23 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const * as trustworthy as a same-module hit. */ #define CONF_QUALIFIED_SUFFIX 0.90 +/* Copy `callee_name` into `out` with every "::" scope separator rewritten as + * ".", so the result composes with dotted candidate QNs. Returns the written + * length. */ +static size_t dotted_callee(char *out, size_t cap, const char *callee_name) { + size_t w = 0; + for (const char *s = callee_name; *s && w + SKIP_ONE < cap;) { + if (s[0] == ':' && s[SKIP_ONE] == ':') { + out[w++] = '.'; + s += PAIR_LEN; + } else { + out[w++] = *s++; + } + } + out[w] = '\0'; + return w; +} + /* When a callee is package/namespace-qualified (Foo::Bar::sub or Foo.Bar.sub), * disambiguate among same-simple-name candidates by matching the FULL qualified * tail against each candidate QN at a segment boundary. Returns the sole @@ -1146,45 +1278,66 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const * both reduce to "run", so the bare-name scorer would route every caller to a * single winner. Language agnostic: callees with no separator return NULL and * leave behavior unchanged. */ -static const char *qualified_suffix_match(const qn_array_t *arr, const char *callee_name) { - /* Normalize "::" → "." so the tail composes with dotted candidate QNs. */ +static const char *qualified_suffix_match(const cbm_registry_t *r, const qn_array_t *arr, + const char *callee_name, int arity) { char dotted[CBM_SZ_512]; - size_t w = 0; - for (const char *s = callee_name; *s && w + SKIP_ONE < sizeof(dotted);) { - if (s[0] == ':' && s[1] == ':') { - dotted[w++] = '.'; - s += 2; - } else { - dotted[w++] = *s++; - } - } - dotted[w] = '\0'; + size_t w = dotted_callee(dotted, sizeof(dotted), callee_name); /* Must be qualified (contain a '.') — a bare name matches every candidate * and carries no disambiguating signal. */ if (!strchr(dotted, '.')) { return NULL; } + /* Two tallies: candidates whose DOTTED identity matches the callee tail, + * and the subset of those whose arity fence equals the call site's. An + * arity fence is a discriminator, not part of the tail the callee text + * names, so it is stripped before comparing -- without that, every fenced + * definition is invisible to the one strategy built to disambiguate a + * qualified callee. */ const char *match = NULL; + int match_count = 0; + const char *arity_match = NULL; + int arity_match_count = 0; for (int i = 0; i < arr->count; i++) { const char *qn = arr->items[i]; - size_t qlen = strlen(qn); + char bare[CBM_SZ_512]; + const char *cmp_qn = qn; + int cand_arity = r->has_arity_fence ? qn_fence_arity(qn) : CBM_ARITY_NONE; + if (cand_arity >= 0) { + const char *fence = strrchr(qn, '#'); + size_t blen = (size_t)(fence - qn); + if (blen >= sizeof(bare)) { + continue; + } + memcpy(bare, qn, blen); + bare[blen] = '\0'; + cmp_qn = bare; + } + size_t qlen = strlen(cmp_qn); if (qlen < w) { continue; } - const char *tail = qn + (qlen - w); + const char *tail = cmp_qn + (qlen - w); if (strcmp(tail, dotted) != 0) { continue; } /* Segment boundary: tail is the whole QN or is preceded by '.'. */ - if (tail != qn && tail[-1] != '.') { + if (tail != cmp_qn && tail[-SKIP_ONE] != '.') { continue; } - if (match) { - return NULL; /* ambiguous — more than one qualified tail matches */ - } + match_count++; match = qn; + if (arity >= 0 && cand_arity == arity) { + arity_match_count++; + arity_match = qn; + } + } + if (arity_match_count == CBM_COUNT_ONE) { + return arity_match; } - return match; + if (match_count == CBM_COUNT_ONE) { + return match; + } + return NULL; /* zero, or several and no arity to choose between them */ } /* A dotted callee whose FIRST segment starts upper-case names a type — URLSession, @@ -1278,10 +1431,35 @@ static bool receiver_chain_admits(const char *callee_name, const char *candidate return false; } +/* Arities of one function look like several candidates to every scorer in + * resolve_name_lookup. When the call site wrote N arguments and exactly one + * candidate is fenced at N, that IS the answer -- letting import distance + * choose instead would pick by path proximity among functions that are not even + * interchangeable. Only fenced candidates are considered, so a language with no + * fences reaches this with nothing to match and gets NULL. */ +static const char *sole_fenced_candidate(const cbm_registry_t *r, const qn_array_t *arr, + const char *callee_name, int arity) { + if (arity < 0 || arr->count <= CBM_COUNT_ONE || !r->has_arity_fence) { + return NULL; + } + const char *fenced = NULL; + int fenced_count = 0; + for (int i = 0; i < arr->count; i++) { + if (qn_fence_arity(arr->items[i]) == arity) { + fenced_count++; + fenced = arr->items[i]; + } + } + if (fenced_count != CBM_COUNT_ONE || !receiver_chain_admits(callee_name, fenced)) { + return NULL; + } + return fenced; +} + /* Strategy 3+4: Name lookup + suffix match */ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char *callee_name, - const char *module_qn, const char **import_vals, - int import_count) { + const char *module_qn, int arity, + const char **import_vals, int import_count) { const char *lookup = simple_name(callee_name); qn_array_t *arr = cbm_ht_get(r->by_name, lookup); if (!arr || arr->count == 0) { @@ -1295,12 +1473,17 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char * candidates by full qualified tail, before bare-name scoring collapses * them onto a single winner. */ if (arr->count > 1) { - const char *q = qualified_suffix_match(arr, callee_name); + const char *q = qualified_suffix_match(r, arr, callee_name, arity); if (q) { return (cbm_resolution_t){q, "qualified_suffix", CONF_QUALIFIED_SUFFIX, REG_RESOLVED}; } } + const char *fenced = sole_fenced_candidate(r, arr, callee_name, arity); + if (fenced) { + return (cbm_resolution_t){fenced, "unique_name", CONF_UNIQUE_NAME, REG_RESOLVED}; + } + /* Strategy 3: unique name */ if (arr->count == SKIP_ONE) { if (!receiver_chain_admits(callee_name, arr->items[0])) { @@ -1333,8 +1516,11 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char /* The strategy chain shared by both public resolve variants (no caching here — * cbm_registry_resolve owns the per-file cache). */ static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const char *callee_name, - const char *module_qn, const char **import_map_keys, + const char *module_qn, const cbm_resolve_ctx_t *rx, + const char **import_map_keys, const char **import_map_vals, int import_map_count) { + const char *container_qn = rx ? rx->container_qn : NULL; + int arity = rx ? rx->arity : CBM_ARITY_NONE; /* Split callee at the first path separator: "pkg.Func" → prefix="pkg", * suffix="Func". Rust/C++ use "::" rather than ".", so honor whichever * separator appears first ("lib::square" → prefix="lib", suffix="square"). @@ -1367,11 +1553,12 @@ static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const ch resolve_import_map(r, prefix, suffix, import_map_keys, import_map_vals, import_map_count); if (!(res.qualified_name && res.qualified_name[0])) { /* Strategy 2: same module */ - res = resolve_same_module(r, callee_name, suffix, module_qn); + res = resolve_same_module(r, callee_name, suffix, module_qn, container_qn, arity); } if (!(res.qualified_name && res.qualified_name[0])) { /* Strategy 3+4: name lookup */ - res = resolve_name_lookup(r, callee_name, module_qn, import_map_vals, import_map_count); + res = resolve_name_lookup(r, callee_name, module_qn, arity, import_map_vals, + import_map_count); } return res; } @@ -1379,22 +1566,46 @@ static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const ch cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count) { + return cbm_registry_resolve_ctx(r, callee_name, module_qn, NULL, import_map_keys, + import_map_vals, import_map_count); +} + +cbm_resolution_t cbm_registry_resolve_ctx(const cbm_registry_t *r, const char *callee_name, + const char *module_qn, const cbm_resolve_ctx_t *rx, + const char **import_map_keys, + const char **import_map_vals, int import_map_count) { if (!r || !callee_name) { return empty_result(); } + const char *container_qn = rx ? rx->container_qn : NULL; + int arity = rx ? rx->arity : CBM_ARITY_NONE; /* Per-file cache: same callee_name in N call sites → 1 chain walk - * + N-1 O(1) hash hits. module_qn is constant per file so the - * cache key only needs callee_name. */ + * + N-1 O(1) hash hits. module_qn is constant per file, but container_qn + * is NOT — one Elixir file holds several defmodules — so a container makes + * it part of the key. */ + /* The per-file cache key is the callee name alone unless something else + * can change the answer. `container_qn` can: one file holds several + * source-named containers. `arity` can only where a registered QN carries + * a fence -- widening the key for every language would split one entry per + * distinct argument count and buy nothing. */ + bool arity_matters = arity >= 0 && r->has_arity_fence; + char keybuf[CBM_SZ_512]; + const char *cache_key = callee_name; + if ((container_qn && container_qn[0]) || arity_matters) { + (void)snprintf(keybuf, sizeof(keybuf), "%s\x1f%d\x1f%s", container_qn ? container_qn : "", + arity_matters ? arity : CBM_ARITY_NONE, callee_name); + cache_key = keybuf; + } if (_resolve_cache) { resolve_cache_entry_t *cached = - (resolve_cache_entry_t *)cbm_ht_get(_resolve_cache, callee_name); + (resolve_cache_entry_t *)cbm_ht_get(_resolve_cache, cache_key); if (cached) { return cached->res; } } - cbm_resolution_t res = registry_resolve_chain(r, callee_name, module_qn, import_map_keys, + cbm_resolution_t res = registry_resolve_chain(r, callee_name, module_qn, rx, import_map_keys, import_map_vals, import_map_count); /* Data relations (Table/View) are lineage-only registry members: common @@ -1415,7 +1626,7 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle resolve_cache_entry_t *e = (resolve_cache_entry_t *)malloc(sizeof(*e)); if (e) { e->res = res; - char *kdup = strdup(callee_name); + char *kdup = strdup(cache_key); if (kdup) { cbm_ht_set(_resolve_cache, kdup, e); } else { @@ -1437,7 +1648,7 @@ cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const cha * and stores the relation-vetoed answer of the default variant — sharing * it would poison one variant with the other's semantics. SQL files hold * few distinct relation refs, so the chain walk stays cheap. */ - return registry_resolve_chain(r, callee_name, module_qn, import_map_keys, import_map_vals, + return registry_resolve_chain(r, callee_name, module_qn, NULL, import_map_keys, import_map_vals, import_map_count); } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index e210691193..355e58a907 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15111,7 +15111,9 @@ TEST(pipeline_objectscript_export_range_join_keeps_one_trailing_marker) { * Everything below is a measured defect the graph used to carry: * - fetch/1, fetch/2 and fetch/3 in one module collapsed to ONE node; * - three `def message/1` in three modules in one file collapsed to one; - * - no Class -> Function containment edge existed at all. */ + * - no Class -> Function containment edge existed at all; + * - `Keyword.get(opts, :reason)` bound to an unrelated local get/1; + * - a fully qualified cross-module call produced no CALLS edge. */ TEST(pipeline_elixir_container_and_arity_identity) { char *repo = th_mktempdir("cbm_elixir_identity"); ASSERT_NOT_NULL(repo); @@ -15172,6 +15174,10 @@ TEST(pipeline_elixir_container_and_arity_identity) { ASSERT_TRUE(cross_file_edge_exists(st, project, "Fx.Store", "fetch", "DEFINES_METHOD")); ASSERT_TRUE(cross_file_edge_exists(st, project, "Fx.ErrB", "message", "DEFINES_METHOD")); + /* The qualified cross-module call resolves; the intra-module one does too. */ + ASSERT_TRUE(cross_file_edge_exists(st, project, "run", "get", "CALLS")); + ASSERT_TRUE(cross_file_edge_exists(st, project, "run", "fetch", "CALLS")); + cbm_store_close(st); cbm_pipeline_free(p); PASS(); diff --git a/tests/test_registry.c b/tests/test_registry.c index 1ef56f64c6..54b45388ca 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -373,6 +373,161 @@ TEST(registry_indexes_a_dotted_name_under_its_tail_too) { PASS(); } +/* An arity fence is a discriminator, not part of the dotted tail the callee + * text names. A qualified callee must therefore still find a fenced candidate, + * and the call site's argument count is what chooses among the arities. */ +TEST(resolve_qualified_suffix_sees_through_arity_fence) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "fetch", "proj.lib.store.Fx.Store.fetch#1", "Function"); + cbm_registry_add(r, "fetch", "proj.lib.store.Fx.Store.fetch#3", "Function"); + cbm_registry_add(r, "fetch", "proj.lib.other.Fx.Other.fetch#1", "Function"); + + cbm_resolve_ctx_t one = {.container_qn = NULL, .arity = 1}; + cbm_resolution_t a = cbm_registry_resolve_ctx(r, "Fx.Store.fetch", "proj.lib.caller", &one, + NULL, NULL, 0); + ASSERT_STR_EQ(a.qualified_name, "proj.lib.store.Fx.Store.fetch#1"); + + cbm_resolve_ctx_t three = {.container_qn = NULL, .arity = 3}; + cbm_resolution_t b = cbm_registry_resolve_ctx(r, "Fx.Store.fetch", "proj.lib.caller", &three, + NULL, NULL, 0); + ASSERT_STR_EQ(b.qualified_name, "proj.lib.store.Fx.Store.fetch#3"); + + /* An arity no candidate carries cannot pick one of the two Fx.Store + * arities, so the qualified strategy declines rather than guessing. */ + cbm_resolve_ctx_t nine = {.container_qn = NULL, .arity = 9}; + cbm_resolution_t c = + cbm_registry_resolve_ctx(r, "Fx.Store.fetch", "proj.lib.caller", &nine, NULL, NULL, 0); + ASSERT_TRUE(!c.strategy || strcmp(c.strategy, "qualified_suffix") != 0); + + cbm_registry_free(r); + PASS(); +} + +/* Where a language names its container in the source, the FILE QN is not the + * container, so an intra-container call cannot be found by the same-module + * strategy without the caller's own container. Without this it degraded to the + * bare-name scorer at roughly half the confidence — quietly, because the + * scorer often still lands on a right-looking answer. */ +TEST(resolve_same_module_uses_caller_container) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "fetch", "proj.lib.store.Fx.Store.fetch#1", "Function"); + cbm_registry_add(r, "fetch", "proj.lib.store.Fx.Store.fetch#2", "Function"); + cbm_registry_add(r, "fetch", "proj.lib.other.Fx.Other.fetch#1", "Function"); + + cbm_resolve_ctx_t rx = {.container_qn = "proj.lib.store.Fx.Store", .arity = 2}; + cbm_resolution_t res = + cbm_registry_resolve_ctx(r, "fetch", "proj.lib.store", &rx, NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.lib.store.Fx.Store.fetch#2"); + ASSERT_STR_EQ(res.strategy, "same_module"); + + /* Without the container the file QN alone cannot compose the candidate. */ + cbm_resolution_t bare = + cbm_registry_resolve(r, "fetch", "proj.lib.store", NULL, NULL, 0); + ASSERT_TRUE(!bare.strategy || strcmp(bare.strategy, "same_module") != 0); + + cbm_registry_free(r); + PASS(); +} + +/* Resolution must be MONOTONE: registering an unrelated symbol may not + * re-point an already-resolved edge. It did, because a qualified callee + * arrived as a bare name — one candidate resolved by unique_name, and a second + * same-named candidate anywhere in the tree flipped the strategy to + * proximity scoring, which picked by path distance. */ +TEST(resolve_qualified_is_monotone_under_unrelated_additions) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "get", "proj.lib.probe.Fx.Probe.get#1", "Function"); + + cbm_resolution_t before = + cbm_registry_resolve(r, "Fx.Probe.get", "proj.lib.store", NULL, NULL, 0); + ASSERT_STR_EQ(before.qualified_name, "proj.lib.probe.Fx.Probe.get#1"); + + /* One unrelated file, in a module the call never names. */ + cbm_registry_add(r, "get", "proj.lib.zz_collide.Fx.Collide.get#1", "Function"); + cbm_registry_resolve_cache_end(); + + cbm_resolution_t after = + cbm_registry_resolve(r, "Fx.Probe.get", "proj.lib.store", NULL, NULL, 0); + ASSERT_STR_EQ(after.qualified_name, "proj.lib.probe.Fx.Probe.get#1"); + + cbm_registry_free(r); + PASS(); +} + +/* A qualified callee naming a container that holds no such symbol must resolve + * to nothing rather than to an unrelated local of the same bare name. This is + * the `Keyword.get(opts, :reason)` case: the receiver chain is the only + * evidence that the project's own get/1 is the wrong answer. */ +TEST(resolve_foreign_receiver_does_not_bind_local_name) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "get", "proj.lib.probe.Fx.Probe.get#1", "Function"); + cbm_registry_add(r, "put", "proj.lib.probe.Fx.Probe.put#3", "Function"); + + cbm_resolution_t kw = cbm_registry_resolve(r, "Keyword.get", "proj.lib.store", NULL, NULL, 0); + ASSERT_TRUE(!kw.qualified_name || kw.qualified_name[0] == '\0'); + + cbm_resolution_t mp = cbm_registry_resolve(r, "Map.put", "proj.lib.store", NULL, NULL, 0); + ASSERT_TRUE(!mp.qualified_name || mp.qualified_name[0] == '\0'); + + /* The genuinely qualified call still resolves. */ + cbm_resolution_t ok = + cbm_registry_resolve(r, "Fx.Probe.get", "proj.lib.store", NULL, NULL, 0); + ASSERT_STR_EQ(ok.qualified_name, "proj.lib.probe.Fx.Probe.get#1"); + + cbm_registry_free(r); + PASS(); +} + +/* The same-module suffix fallback composes its candidate from the caller's + * module and the callee's SUFFIX, discarding the qualifier the call site wrote. + * Routing it through receiver_chain_admits is NOT gated on the new context + * argument: cbm_registry_resolve reaches it too, so every language that writes + * receiver chains is covered. Nothing here carries an arity fence and no + * context is passed, so the container and arity machinery is off by + * construction and the guard is the only thing under test. */ +TEST(resolve_receiver_guard_is_language_agnostic) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "parse", "proj.com.acme.Order.parse", "Method"); + cbm_registry_add(r, "render", "proj.app.views.render", "Function"); + cbm_registry_add(r, "max", "proj.web.cart.max", "Function"); + + /* Java: java.time.LocalDate.parse is not Order's own parse. */ + cbm_resolution_t jv = + cbm_registry_resolve(r, "LocalDate.parse", "proj.com.acme.Order", NULL, NULL, 0); + ASSERT_TRUE(!jv.qualified_name || jv.qualified_name[0] == '\0'); + + /* Python: a Template's render is not the module's own render. */ + cbm_resolution_t py = + cbm_registry_resolve(r, "Template.render", "proj.app.views", NULL, NULL, 0); + ASSERT_TRUE(!py.qualified_name || py.qualified_name[0] == '\0'); + + /* JavaScript: Math.max is not the cart's own max. */ + cbm_resolution_t js = cbm_registry_resolve(r, "Math.max", "proj.web.cart", NULL, NULL, 0); + ASSERT_TRUE(!js.qualified_name || js.qualified_name[0] == '\0'); + + /* A chain segment naming the candidate's own ancestry still binds, so the + * guard withholds a foreign receiver rather than every qualified callee. */ + cbm_resolution_t own = + cbm_registry_resolve(r, "Order.parse", "proj.com.acme.Order", NULL, NULL, 0); + ASSERT_STR_EQ(own.qualified_name, "proj.com.acme.Order.parse"); + + /* Carve-out: a lower-case root names a value whose type the chain does not + * show, so it passes through unchanged. */ + cbm_registry_add(r, "close", "proj.io.handle.close", "Function"); + cbm_resolution_t lc = cbm_registry_resolve(r, "file.close", "proj.io.handle", NULL, NULL, 0); + ASSERT_STR_EQ(lc.qualified_name, "proj.io.handle.close"); + + /* Carve-out: an ALL_CAPS root with an underscore is a constant holding a + * value, not a type. */ + cbm_registry_add(r, "lower", "proj.iso.codes.lower", "Function"); + cbm_resolution_t cc = + cbm_registry_resolve(r, "ISO_4217_URL.lower", "proj.iso.codes", NULL, NULL, 0); + ASSERT_STR_EQ(cc.qualified_name, "proj.iso.codes.lower"); + + cbm_registry_free(r); + PASS(); +} + TEST(resolve_import_map) { cbm_registry_t *r = cbm_registry_new(); cbm_registry_add(r, "Process", "proj.pkg.worker.Process", "Function"); @@ -1251,6 +1406,11 @@ SUITE(registry) { RUN_TEST(resolve_qualified_ambiguous_tail_falls_through); RUN_TEST(registry_indexes_by_passed_name_not_qn_tail); RUN_TEST(registry_indexes_a_dotted_name_under_its_tail_too); + RUN_TEST(resolve_qualified_suffix_sees_through_arity_fence); + RUN_TEST(resolve_same_module_uses_caller_container); + RUN_TEST(resolve_qualified_is_monotone_under_unrelated_additions); + RUN_TEST(resolve_foreign_receiver_does_not_bind_local_name); + RUN_TEST(resolve_receiver_guard_is_language_agnostic); RUN_TEST(resolve_import_map); RUN_TEST(resolve_import_map_bare_function); RUN_TEST(resolve_import_map_bare_alias); From ca7e61d85d7607f81f4c4ac26e6c6ce78343102c Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:23 -0700 Subject: [PATCH 11/13] fix(incremental): re-link inbound edges across an arity change Arity is now part of a node's identity, so adding or removing a parameter renames the node. An edit that changed both a function's body and its signature dropped every inbound cross-file edge into it until each caller's file happened to be re-parsed. A full reindex and an incremental reindex of the same tree disagreed. incr_restore_inbound_edges keeps exact QN as its primary key, as it always was. When that misses and the captured target carries a fence, a node whose QN differs only by its fence is the same function, so the edge follows it. Ambiguity (several arities of the same name in the same container) keeps the old behaviour and drops the edge, because nothing says which arity the caller meant. The lookup goes through an index. Restoring N captured edges against a graph of M nodes scanned all M for every fenced miss, so the pass cost the product of the two largest quantities in an incremental reindex. One walk builds the table and each miss after it is a hash lookup. The walk is deferred to the first fenced miss, so a corpus with no arity fences (every language but Elixir today) builds nothing and pays nothing. An allocation failure mid-build marks the index unusable rather than partial. A partial index would answer "sole match" for a name whose rival arity never got inserted. Measured on a two-file Elixir fixture: caller calls `landing/1`, then the file declaring `landing` is edited to `landing/2` and reindexed incrementally. Before this commit the CALLS edge is gone from the incremental store and present in a full reindex of the same final tree; after, both stores bind the same `...landing#2`. With two surviving arities the incremental store drops the edge and so does the full reindex. The test there asserts that the two stores agree, not that the edge survives. tests/test_pipeline.c: pipeline_incremental_preserves_edges_across_arity_change and pipeline_incremental_ambiguous_arity_successor_matches_full_index. Both compare the target's qualified_name rather than its bare name, because cross_file_edge_exists matches nodes by name and every arity of one function looks alike to it. Depends on the module/arity identity and resolution commits, which make the pre-edit edge exist to be captured. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- src/pipeline/pipeline_incremental.c | 137 +++++++++++++++++++- tests/test_pipeline.c | 187 ++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 1 deletion(-) diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 894b732572..cf7e8e0fa0 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -25,6 +25,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24 }; #include "discover/discover.h" #include "foundation/log.h" #include "foundation/hash_table.h" +#include "foundation/mem_core.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/compat_thread.h" @@ -988,19 +989,153 @@ static void incr_capture_inbound_edge(const cbm_gbuf_edge_t *edge, void *userdat cap->count++; } +/* Strip a QN's '#' arity fence into `out`. Local to this TU for the + * same reason registry.c keeps its own copy: the pipeline does not pull in the + * extraction header. */ +static void incr_strip_arity(char *out, size_t cap, const char *qn) { + size_t len = qn ? strlen(qn) : 0; + const char *hash = qn ? strrchr(qn, '#') : NULL; + if (hash && hash[SKIP_ONE]) { + bool digits = true; + for (const char *p = hash + SKIP_ONE; *p; p++) { + if (*p < '0' || *p > '9') { + digits = false; + break; + } + } + if (digits) { + len = (size_t)(hash - qn); + } + } + if (len > cap - SKIP_ONE) { + len = cap - SKIP_ONE; + } + if (len > 0) { + memcpy(out, qn, len); + } + out[len] = '\0'; +} + +/* Arity-stripped QN -> the fenced node(s) that strip to it. `count` is kept, + * not just the node, so several arities of one name still decline to guess. + * + * This is an index because the lookup it serves is a per-edge one: restoring N + * captured edges against a graph of M nodes scanned all M for every fenced miss, + * so the pass cost the product of the two largest quantities in an incremental + * reindex. One walk builds the table; each miss after it is a hash lookup. The + * walk is deferred to the first fenced miss, so a language with no arity fences + * -- every language but Elixir today -- builds nothing. */ +typedef struct { + char *key; /* heap copy; the table borrows keys, it does not own them */ + const cbm_gbuf_node_t *node; + int count; +} incr_arity_slot_t; + +typedef struct { + CBMHashTable *by_stripped; + bool built; + bool ok; /* an allocation failed mid-build: the index is partial, so it is + * not consulted at all -- a partial index would answer "sole + * match" for a name whose rival arity never got inserted. */ +} incr_arity_index_t; + +static void incr_arity_index_visit(const cbm_gbuf_node_t *node, void *userdata) { + incr_arity_index_t *idx = (incr_arity_index_t *)userdata; + if (!idx->ok || !node->qualified_name || !strchr(node->qualified_name, '#')) { + return; + } + char stripped[CBM_SZ_512]; + incr_strip_arity(stripped, sizeof(stripped), node->qualified_name); + incr_arity_slot_t *slot = cbm_ht_get(idx->by_stripped, stripped); + if (slot) { + slot->count++; + return; + } + slot = cbm_calloc(CBM_MEM_CLASS_HASH_TABLE, sizeof(*slot)); + char *key = cbm_mem_strdup(CBM_MEM_CLASS_HASH_TABLE, stripped); + if (!slot || !key) { + cbm_free(CBM_MEM_CLASS_HASH_TABLE, slot); + cbm_free(CBM_MEM_CLASS_HASH_TABLE, key); + idx->ok = false; + cbm_log_warn("incremental.arity_index_oom", "qn", node->qualified_name); + return; + } + slot->key = key; + slot->node = node; + slot->count = CBM_COUNT_ONE; + cbm_ht_set(idx->by_stripped, key, slot); +} + +static void incr_arity_index_free_visitor(const char *key, void *value, void *userdata) { + (void)key; + (void)userdata; + incr_arity_slot_t *slot = (incr_arity_slot_t *)value; + cbm_free(CBM_MEM_CLASS_HASH_TABLE, slot->key); + cbm_free(CBM_MEM_CLASS_HASH_TABLE, slot); +} + +static void incr_arity_index_build(incr_arity_index_t *idx, const cbm_gbuf_t *gbuf) { + idx->built = true; + idx->by_stripped = cbm_ht_create((uint32_t)cbm_gbuf_node_count(gbuf)); + idx->ok = idx->by_stripped != NULL; + if (!idx->ok) { + return; + } + cbm_gbuf_foreach_node(gbuf, incr_arity_index_visit, idx); +} + +static void incr_arity_index_free(incr_arity_index_t *idx) { + if (!idx->by_stripped) { + return; + } + cbm_ht_foreach(idx->by_stripped, incr_arity_index_free_visitor, NULL); + cbm_ht_free(idx->by_stripped); + idx->by_stripped = NULL; +} + +/* The sole node whose QN differs from `fenced_qn` only by its arity fence, or + * NULL when there is none or more than one. */ +static const cbm_gbuf_node_t *incr_arity_sole_match(incr_arity_index_t *idx, const cbm_gbuf_t *gbuf, + const char *fenced_qn) { + if (!idx->built) { + incr_arity_index_build(idx, gbuf); + } + if (!idx->ok) { + return NULL; + } + char want[CBM_SZ_512]; + incr_strip_arity(want, sizeof(want), fenced_qn); + const incr_arity_slot_t *slot = cbm_ht_get(idx->by_stripped, want); + return (slot && slot->count == CBM_COUNT_ONE) ? slot->node : NULL; +} + /* Re-link snapshotted inbound edges to the freshly re-created target nodes. - * Returns the number of edges re-linked. */ + * Returns the number of edges re-linked. + * + * Exact QN is the primary key, as it always was. The secondary key exists + * because arity is now part of identity for some languages: adding or removing + * a parameter RENAMES the node, so a body-and-signature edit would otherwise + * drop every inbound cross-file edge into that function until each caller's + * file happened to be re-parsed -- a full-vs-incremental divergence created by + * the identity change itself. A unique node that differs only in its fence is + * the same function, so the edge follows it. Ambiguity (several arities) keeps + * the old behaviour and drops the edge rather than guessing. */ static int incr_restore_inbound_edges(cbm_gbuf_t *gbuf, cbm_edge_capture_t *cap) { int restored = 0; + incr_arity_index_t idx = {NULL, false, false}; for (int i = 0; i < cap->count; i++) { cbm_saved_edge_t *s = &cap->items[i]; const cbm_gbuf_node_t *src = cbm_gbuf_find_by_qn(gbuf, s->source_qn); const cbm_gbuf_node_t *tgt = cbm_gbuf_find_by_qn(gbuf, s->target_qn); + if (src && !tgt && strchr(s->target_qn, '#')) { + tgt = incr_arity_sole_match(&idx, gbuf, s->target_qn); + } if (src && tgt) { cbm_gbuf_insert_edge(gbuf, src->id, tgt->id, s->type, s->props); restored++; } } + incr_arity_index_free(&idx); return restored; } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 355e58a907..9bc007c195 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1017,6 +1017,44 @@ static bool cross_file_edge_exists(cbm_store_t *s, const char *project, const ch return found; } +/* Copy the qualified_name of the node a named CALLS edge lands on, or NULL. + * Caller frees. Used to compare WHICH arity an edge bound to, which + * cross_file_edge_exists cannot see: it matches nodes by bare name, so every + * arity of one function looks alike to it. */ +static char *cross_file_call_target_qn(cbm_store_t *s, const char *project, const char *src_name, + const char *tgt_name) { + cbm_node_t *srcs = NULL; + cbm_node_t *tgts = NULL; + int sc = 0; + int tc = 0; + cbm_store_find_nodes_by_name(s, project, src_name, &srcs, &sc); + cbm_store_find_nodes_by_name(s, project, tgt_name, &tgts, &tc); + char *qn = NULL; + for (int i = 0; i < sc && !qn; i++) { + cbm_edge_t *edges = NULL; + int ec = 0; + cbm_store_find_edges_by_source_type(s, srcs[i].id, "CALLS", &edges, &ec); + for (int j = 0; j < ec && !qn; j++) { + for (int k = 0; k < tc; k++) { + if (edges[j].target_id == tgts[k].id && tgts[k].qualified_name) { + qn = strdup(tgts[k].qualified_name); + break; + } + } + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + } + if (srcs) { + cbm_store_free_nodes(srcs, sc); + } + if (tgts) { + cbm_store_free_nodes(tgts, tc); + } + return qn; +} + static bool cross_file_call_exists(cbm_store_t *s, const char *project, const char *src_name, const char *tgt_name) { return cross_file_edge_exists(s, project, src_name, tgt_name, "CALLS"); @@ -15107,6 +15145,153 @@ TEST(pipeline_objectscript_export_range_join_keeps_one_trailing_marker) { } #endif +/* Arity is part of node identity for a language that overloads by it, so + * changing a function's signature RENAMES its node. The incremental pass + * snapshots inbound cross-file edges by the endpoint's exact qualified_name and + * drops any whose target QN no longer exists, so without a secondary key an + * ordinary edit -- add a parameter -- would silently drop every inbound edge + * into that function until each caller's file happened to be re-parsed. That is + * a full-vs-incremental divergence created by the identity change itself. */ +TEST(pipeline_incremental_preserves_edges_across_arity_change) { + char *repo = th_mktempdir("cbm_arity_incr"); + ASSERT_NOT_NULL(repo); + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s", repo); + + ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "lib/target.ex"), + "defmodule Fx.Target do\n" + " def landing(a), do: a\n" + "end\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "lib/caller.ex"), + "defmodule Fx.Caller do\n" + " def invoke(x), do: Fx.Target.landing(x)\n" + "end\n"), + 0); + + char db_path[600]; + snprintf(db_path, sizeof(db_path), "%s/arity_incr.db", tmpdir); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + const char *project1 = cbm_pipeline_project_name(p1); + cbm_store_t *s1 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s1); + ASSERT_TRUE(cross_file_edge_exists(s1, project1, "invoke", "landing", "CALLS")); + cbm_store_close(s1); + cbm_pipeline_free(p1); + + /* Add a parameter: landing/1 becomes landing/2, a different node. Only the + * TARGET file changes, so caller.ex is never re-parsed. */ + ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "lib/target.ex"), + "defmodule Fx.Target do\n" + " def landing(a, b), do: {a, b}\n" + "end\n"), + 0); + + cbm_pipeline_t *p2 = cbm_pipeline_new(tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + const char *project2 = cbm_pipeline_project_name(p2); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + ASSERT_TRUE(cross_file_edge_exists(s2, project2, "invoke", "landing", "CALLS")); + cbm_store_close(s2); + cbm_pipeline_free(p2); + PASS(); +} +/* The edge-restore secondary key exists to stop an arity edit from making the + * incremental graph differ from the full one. This pins that equality for the + * case the key itself declines: landing/1 becomes landing/2 AND landing/3, so + * no surviving arity is the unique successor of the captured edge and the + * restore drops it rather than guessing. What lands in the graph is then + * whatever the ordinary resolve produces -- and it must be what a from-scratch + * full index of the same tree produces, down to which arity the edge binds. + * + * MEASURED, not assumed: an incremental run over this change and a full index + * of the same post-change tree both bind invoke#1 -> landing#2. Asserting the + * edge is ABSENT here would be wrong; the restore declining is not the same as + * the edge being gone. + * + * This is a guard, not a red-green: it holds on both sides of the index that + * replaced the per-edge full node scan in incr_restore_inbound_edges. That is + * why it is here -- an index that recorded only "some node has this stripped + * name", and not how many do, would answer "sole match", restore the edge onto + * whichever arity it happened to store, and diverge from the full index. */ +TEST(pipeline_incremental_ambiguous_arity_successor_matches_full_index) { + const char *before = "defmodule Fx.Target do\n" + " def landing(a), do: a\n" + "end\n"; + const char *after = "defmodule Fx.Target do\n" + " def landing(a, b), do: {a, b}\n" + " def landing(a, b, c), do: {a, b, c}\n" + "end\n"; + const char *caller = "defmodule Fx.Caller do\n" + " def invoke(x), do: Fx.Target.landing(x)\n" + "end\n"; + + /* Tree A: indexed at `before`, then re-indexed after the arity change. */ + char *repo_a = th_mktempdir("cbm_arity_incr_amb"); + ASSERT_NOT_NULL(repo_a); + char dir_a[512]; + snprintf(dir_a, sizeof(dir_a), "%s", repo_a); + ASSERT_EQ(th_write_file(TH_PATH(dir_a, "lib/target.ex"), before), 0); + ASSERT_EQ(th_write_file(TH_PATH(dir_a, "lib/caller.ex"), caller), 0); + char db_a[600]; + snprintf(db_a, sizeof(db_a), "%s/arity_incr_amb.db", dir_a); + + cbm_pipeline_t *p1 = cbm_pipeline_new(dir_a, db_a, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + cbm_pipeline_free(p1); + + ASSERT_EQ(th_write_file(TH_PATH(dir_a, "lib/target.ex"), after), 0); + cbm_pipeline_t *p2 = cbm_pipeline_new(dir_a, db_a, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + const char *project_a = cbm_pipeline_project_name(p2); + cbm_store_t *store_a = cbm_store_open_path(db_a); + ASSERT_NOT_NULL(store_a); + char *incremental_qn = cross_file_call_target_qn(store_a, project_a, "invoke", "landing"); + cbm_store_close(store_a); + cbm_pipeline_free(p2); + ASSERT_NOT_NULL(incremental_qn); + + /* Tree B: the same post-change sources, indexed once from scratch. */ + char *repo_b = th_mktempdir("cbm_arity_full_amb"); + ASSERT_NOT_NULL(repo_b); + char dir_b[512]; + snprintf(dir_b, sizeof(dir_b), "%s", repo_b); + ASSERT_EQ(th_write_file(TH_PATH(dir_b, "lib/target.ex"), after), 0); + ASSERT_EQ(th_write_file(TH_PATH(dir_b, "lib/caller.ex"), caller), 0); + char db_b[600]; + snprintf(db_b, sizeof(db_b), "%s/arity_full_amb.db", dir_b); + + cbm_pipeline_t *p3 = cbm_pipeline_new(dir_b, db_b, CBM_MODE_FULL); + ASSERT_NOT_NULL(p3); + ASSERT_EQ(cbm_pipeline_run(p3), 0); + const char *project_b = cbm_pipeline_project_name(p3); + cbm_store_t *store_b = cbm_store_open_path(db_b); + ASSERT_NOT_NULL(store_b); + char *full_qn = cross_file_call_target_qn(store_b, project_b, "invoke", "landing"); + cbm_store_close(store_b); + cbm_pipeline_free(p3); + ASSERT_NOT_NULL(full_qn); + + /* Same arity on both sides. Compare the fenced tail, since the project + * prefix differs between the two temp trees. */ + const char *incremental_tail = strrchr(incremental_qn, '.'); + const char *full_tail = strrchr(full_qn, '.'); + ASSERT_NOT_NULL(incremental_tail); + ASSERT_NOT_NULL(full_tail); + ASSERT_STR_EQ(incremental_tail, full_tail); + ASSERT_NOT_NULL(strchr(incremental_tail, '#')); + + free(incremental_qn); + free(full_qn); + PASS(); +} /* End-to-end identity + resolution guard for a source-named container. * Everything below is a measured defect the graph used to carry: * - fetch/1, fetch/2 and fetch/3 in one module collapsed to ONE node; @@ -15230,6 +15415,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_nix_scoped_binding_calls_resolve); RUN_TEST(pipeline_hcl_block_reference_resolves_to_its_block); RUN_TEST(pipeline_incremental_preserves_cross_file_calls); + RUN_TEST(pipeline_incremental_preserves_edges_across_arity_change); + RUN_TEST(pipeline_incremental_ambiguous_arity_successor_matches_full_index); RUN_TEST(pipeline_elixir_container_and_arity_identity); RUN_TEST(pipeline_objectscript_export_preserves_calls_sequential_parallel); RUN_TEST(pipeline_objectscript_export_incremental_matches_full_relationships); From 4f5cb983b06d704a17f9db0b0218ac4db65a9eb9 Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:23 -0700 Subject: [PATCH 12/13] fix(elixir): record the qualifier a reference was written under Elixir writes every qualified reference as a `dot` node: `Keyword.get` is dot(alias "Keyword", identifier "get"). The usage extractor kept only the identifier half. The qualifier was destroyed before resolution ever saw it, so the usage arrived at the registry as the bare name "get", receiver_chain_admits returned true at its bare-name early return, and every receiver-chain protection was bypassed by construction. That is how an unrelated local `get/1` became the target of a `Keyword.get` reference, and how adding one unrelated file could re-point an already-resolved edge: with one candidate the bare name resolved by unique_name, and a second same-named candidate anywhere in the tree flipped the strategy to proximity scoring, which picks by path distance. is_member_access already recorded that a selector was there; it never recorded what it named. CBMUsage gains `receiver`, NULL when the reference is unqualified. The pipeline composes "." before resolving, and the edge property still reports ref_name, so nothing downstream sees a renamed reference. Scoped to Elixir on purpose. A receiver fed to the registry changes which candidate a usage binds to, and selector shape is a per-grammar claim. result_compact.c relocates the new string with the rest. Every arena-owned string a CBMUsage points at has to be relocated: the pipeline composes a qualified name from `receiver` before resolving, so a stale pointer corrupts silently. It resolves a reference built from whatever bytes the old arena's memory now holds, and the result is a valid-looking USAGE edge onto an arbitrary same-suffixed function, different on every run of the same input. Measured by building a product binary from the incremental re-link commit and one from this commit, indexing the same 970-file Elixir lib tree with each into its own sandbox (`cli index_repository --mode full`), and diffing the `edges` rows out of the two SQLite stores. A USAGE site is keyed by (source qualified_name, callee), which is stable across both builds: USAGE edges 31,844 -> 25,664 USAGE sites present before and gone after 6,245 USAGE sites present after and not before 0 surviving sites whose target changed 1,025 Nothing new appears, 6,245 bindings that the bare name alone justified are withdrawn, and 1,025 of the survivors move to a different target. A qualified reference that named a module the project does not define now resolves to nothing instead of to a same-named local function. The incremental re-link commit's product binary is byte-identical to the resolution commit's on this toolchain, so the "before" column is equally the value at resolution. tests/test_extraction.c: elixir_usage_records_receiver pins the recorded qualifier. extract_compact_relocates_every_usage_string walks every arena-owned pointer on every CBMUsage after cbm_result_compact and asserts each one lands inside the surviving arena block, so a field added later and not relocated fails the test. Its content dependency is the resolution commit, whose receiver-chain guard is what the recorded qualifier feeds. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/cbm.h | 20 ++++++- internal/cbm/extract_usages.c | 28 ++++++++++ internal/cbm/result_compact.c | 7 +++ src/pipeline/pass_parallel.c | 21 ++++++-- src/pipeline/pass_usages.c | 21 ++++++-- tests/test_extraction.c | 99 +++++++++++++++++++++++++++++++++++ 6 files changed, 186 insertions(+), 10 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index a00192a188..37449c9172 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -309,8 +309,24 @@ typedef enum { typedef struct { const char *ref_name; // referenced identifier const char *enclosing_func_qn; // QN of enclosing function (or module QN) - /* Fixed-width fields grouped so the record packs to 40 bytes (was 48; the - * Go corpus holds 4.68M of these). Field meanings unchanged. */ + /* The qualifier the reference was written under, when the extractor can + * see one: `Keyword` in `Keyword.get(opts, :reason)`. Without it a usage + * arrives at the registry as the bare name "get", receiver_chain_admits + * returns true at its bare-name early return, and EVERY receiver-chain + * protection is bypassed by construction -- which is how an unrelated + * local get/1 became the target of a Keyword.get call, and how adding one + * unrelated file re-pointed an already-resolved edge. is_member_access + * below records only THAT a selector was there, never what it named. + * NULL when the reference is unqualified. */ + const char *receiver; + /* Fixed-width fields grouped so the record packs with no interior padding. + * `receiver` above makes that 48 bytes, up from the 40 this comment used to + * report: three pointers, three uint32, two enums and four bools all pack + * exactly, so an 8-byte pointer cannot ride in existing slack and the + * record grows by its full width for every language. On the Go corpus this + * comment already cites -- 4.68M usages -- the qualifier is ~37 MB of extra + * peak extraction footprint, and reference_receiver() returns NULL for + * every language but Elixir. Field meanings unchanged. */ uint32_t lexical_scope_id; // extraction-local scope instance; never graph identity uint32_t site_start_byte; // exact reference-token span; end > start when present uint32_t site_end_byte; // exclusive byte offset in the source file diff --git a/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index 0d747196f4..3982dbdeea 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -2626,6 +2626,33 @@ void cbm_extract_usages(CBMExtractCtx *ctx) { // --- Unified handler: called once per node by the cursor walk --- // Uses WalkState flags instead of parent-chain walks for O(1) context checks. +/* The qualifier a reference was written under, or NULL when unqualified. + * + * Elixir writes every qualified reference as a `dot` node -- `Keyword.get` is + * dot(alias "Keyword", identifier "get") -- and the usage extractor records + * only the identifier half, so the qualifier is destroyed before resolution + * ever sees it. Recovering it here is what lets receiver_chain_admits judge a + * usage the same way it already judges a call. + * + * Deliberately scoped to the languages whose selector shape is known here: a + * receiver fed to the registry changes which candidate a usage binds to, and + * that is a per-grammar claim, not a universal one. */ +static const char *reference_receiver(CBMExtractCtx *ctx, TSNode node) { + if (ctx->language != CBM_LANG_ELIXIR) { + return NULL; + } + TSNode parent = ts_node_parent(node); + if (ts_node_is_null(parent) || strcmp(ts_node_type(parent), "dot") != 0) { + return NULL; + } + TSNode lhs = ts_node_child(parent, 0); + if (ts_node_is_null(lhs) || ts_node_eq(lhs, node)) { + return NULL; + } + char *text = cbm_node_text(ctx->arena, lhs, ctx->source); + return (text && text[0]) ? text : NULL; +} + void handle_usages(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, WalkState *state) { if (emit_direct_perl_coderef_usage(ctx, node, state->enclosing_func_qn, active_lexical_scope_id(state))) { @@ -2728,6 +2755,7 @@ void handle_usages(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Wal * (field_identifier); record that shape — the name alone cannot carry * it, and the Go Field guard keys on it (#1962). */ usage.is_member_access = strcmp(ts_node_type(node), "field_identifier") == 0; + usage.receiver = reference_receiver(ctx, node); stamp_usage_site(ctx, &usage, node, name, state); cbm_usages_push(&ctx->result->usages, ctx->arena, usage); } diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c index d237b2a175..a616948a80 100644 --- a/internal/cbm/result_compact.c +++ b/internal/cbm/result_compact.c @@ -315,6 +315,13 @@ static void cr_walk(cr_ctx_t *c, CBMFileResult *r) { for (int i = 0; i < r->usages.count && r->usages.items; i++) { cr_str(c, &r->usages.items[i].ref_name); cr_str(c, &r->usages.items[i].enclosing_func_qn); + /* Every arena-owned string a CBMUsage points at has to be relocated + * here. `receiver` is one: the pipeline composes "." + * before resolving, so a stale pointer does not crash, it resolves a + * qualified reference built from whatever bytes the old arena's memory + * now holds -- a valid-looking USAGE edge onto an arbitrary same-suffixed + * function, different on every run of the same input. */ + cr_str(c, &r->usages.items[i].receiver); } cr_array(c, (void **)&r->throws.items, r->throws.count, sizeof(CBMThrow)); for (int i = 0; i < r->throws.count && r->throws.items; i++) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 2744505f71..778be375d8 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3159,15 +3159,28 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, usage->enclosing_func_qn)) { container_qn = container_buf; } + /* Resolve under the qualifier the reference was actually written + * with. A bare name reaches receiver_chain_admits' bare-name early + * return, so every receiver-chain protection is bypassed; the + * qualified form is what lets the guard reject an unrelated local + * of the same name and lets qualified_suffix_match find the real + * one. The EDGE property still reports ref_name. */ + char qualified_ref[CBM_SZ_512]; + const char *resolve_ref = usage->ref_name; + if (usage->receiver && usage->receiver[0]) { + (void)snprintf(qualified_ref, sizeof(qualified_ref), "%s.%s", usage->receiver, + usage->ref_name); + resolve_ref = qualified_ref; + } /* A usage is a reference, not an invocation: no argument list, so * no arity to offer. */ cbm_resolve_ctx_t rx = {.container_qn = container_qn, .arity = CBM_ARITY_NONE}; cbm_resolution_t res = (lang == CBM_LANG_SQL) - ? cbm_registry_resolve_lineage(rc->registry, usage->ref_name, module_qn, - imp_keys, imp_vals, imp_count) - : cbm_registry_resolve_ctx(rc->registry, usage->ref_name, module_qn, &rx, - imp_keys, imp_vals, imp_count); + ? cbm_registry_resolve_lineage(rc->registry, resolve_ref, module_qn, imp_keys, + imp_vals, imp_count) + : cbm_registry_resolve_ctx(rc->registry, resolve_ref, module_qn, &rx, imp_keys, + imp_vals, imp_count); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index c677cf65da..1e8533d56c 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -205,6 +205,19 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res usage->enclosing_func_qn)) { container_qn = container_buf; } + /* Resolve under the qualifier the reference was actually written + * with. A bare name reaches receiver_chain_admits' bare-name early + * return, so every receiver-chain protection is bypassed; the + * qualified form is what lets the guard reject an unrelated local + * of the same name and lets qualified_suffix_match find the real + * one. The EDGE property still reports ref_name. */ + char qualified_ref[CBM_SZ_512]; + const char *resolve_ref = usage->ref_name; + if (usage->receiver && usage->receiver[0]) { + (void)snprintf(qualified_ref, sizeof(qualified_ref), "%s.%s", usage->receiver, + usage->ref_name); + resolve_ref = qualified_ref; + } /* A usage is a reference, not an invocation: no argument list, so * no arity to offer. * @@ -215,10 +228,10 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res cbm_resolve_ctx_t rx = {.container_qn = container_qn, .arity = CBM_ARITY_NONE}; cbm_resolution_t res = (lang == CBM_LANG_SQL) - ? cbm_registry_resolve_lineage(ctx->registry, usage->ref_name, module_qn, - imp_keys, imp_vals, imp_count) - : cbm_registry_resolve_ctx(ctx->registry, usage->ref_name, module_qn, &rx, - imp_keys, imp_vals, imp_count); + ? cbm_registry_resolve_lineage(ctx->registry, resolve_ref, module_qn, imp_keys, + imp_vals, imp_count) + : cbm_registry_resolve_ctx(ctx->registry, resolve_ref, module_qn, &rx, imp_keys, + imp_vals, imp_count); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index a9ca3b0959..7d1d8359ca 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2308,6 +2308,34 @@ TEST(elixir_call_scope_qn_matches_def_qn) { PASS(); } +/* A qualified reference used to reach the registry as the BARE name: the + * extractor recorded `get` for `Keyword.get(opts, :reason)` and threw the + * qualifier away, so receiver_chain_admits returned true at its bare-name + * early return and every receiver-chain protection was bypassed. */ +TEST(elixir_usage_records_receiver) { + CBMFileResult *r = extract("defmodule Fx.Store do\n" + " def run(opts) do\n" + " Keyword.get(opts, :reason)\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "lib/store.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + int seen = 0; + for (int i = 0; i < r->usages.count; i++) { + if (!r->usages.items[i].ref_name || + strcmp(r->usages.items[i].ref_name, "get") != 0) { + continue; + } + seen = 1; + ASSERT_NOT_NULL(r->usages.items[i].receiver); + ASSERT_STR_EQ("Keyword", r->usages.items[i].receiver); + } + ASSERT_EQ(1, seen); + cbm_free_result(r); + PASS(); +} + /* --- Haskell --- */ TEST(haskell_function) { CBMFileResult *r = extract("add :: Int -> Int -> Int\nadd x y = x + y\n\nmultiply :: Int -> " @@ -8966,6 +8994,75 @@ static size_t arena_capacity(const CBMArena *a) { return total; } +/* True when `p` points inside one of the arena's own blocks. After + * cbm_result_compact there is exactly one, so this is "did compaction relocate + * this string, or is it still pointing at the freed pre-compaction arena". */ +static bool in_arena(const CBMArena *a, const void *p) { + for (int i = 0; i < a->nblocks; i++) { + const char *base = a->blocks[i]; + if (base && (const char *)p >= base && (const char *)p < base + a->block_sizes[i]) { + return true; + } + } + return false; +} + +/* cbm_result_compact copies a CBMFileResult into one exact arena block and + * frees the old one, so EVERY arena-owned pointer the result holds has to be + * named in cr_walk. A field that is not stays pointing into freed memory, and + * the failure is silent: nothing crashes, the bytes still parse as a string, + * and the pipeline builds a real-looking edge out of whatever the recycled + * memory now holds -- a different edge on every run of the same input. + * + * The sibling test above uses a Python fixture, where a usage's `receiver` is + * always NULL, so it cannot see an unrelocated receiver at all. This one uses + * a qualified Elixir reference, which is the shape that sets it, and asserts + * containment rather than string equality: a stale pointer very often still + * reads the right characters, which is exactly why this defect survives a + * value comparison. */ +TEST(extract_compact_relocates_every_usage_string) { + CBMFileResult *r = extract("defmodule Fx.Runner do\n" + " def run(opts) do\n" + " Keyword.get(opts, :reason)\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "lib/runner.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* The fixture has to actually produce the field under test. */ + int with_receiver = 0; + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].receiver) { + with_receiver++; + ASSERT_STR_EQ("Keyword", r->usages.items[i].receiver); + } + } + ASSERT_EQ(1, with_receiver); + + cbm_result_compact(r); + ASSERT_EQ(r->arena.nblocks, 1); + + with_receiver = 0; + for (int i = 0; i < r->usages.count; i++) { + const CBMUsage *u = &r->usages.items[i]; + if (u->ref_name && u->ref_name[0]) { + ASSERT(in_arena(&r->arena, u->ref_name)); + } + if (u->enclosing_func_qn && u->enclosing_func_qn[0]) { + ASSERT(in_arena(&r->arena, u->enclosing_func_qn)); + } + if (u->receiver) { + with_receiver++; + ASSERT(in_arena(&r->arena, u->receiver)); + ASSERT_STR_EQ("Keyword", u->receiver); + } + } + ASSERT_EQ(1, with_receiver); + cbm_free_result(r); + PASS(); +} + TEST(extract_compact_keeps_every_field_and_shrinks_the_arena) { CBMFileResult *r = extract(COMPACT_PY_SRC, CBM_LANG_PYTHON, "t", "svc.py"); CBMFileResult *ref = extract(COMPACT_PY_SRC, CBM_LANG_PYTHON, "t", "svc.py"); @@ -9293,6 +9390,7 @@ SUITE(extraction) { RUN_TEST(extract_spill_round_trip_keeps_every_field); RUN_TEST(extract_lsp_skipped_file_gets_no_walk); RUN_TEST(extract_walk_truncated_when_a_node_budget_is_set); + RUN_TEST(extract_compact_relocates_every_usage_string); /* Initialize extraction library */ cbm_init(); @@ -9440,6 +9538,7 @@ SUITE(extraction) { RUN_TEST(elixir_defs_inside_macro_blocks_are_extracted); RUN_TEST(elixir_multi_clause_function_stays_one_identity); RUN_TEST(elixir_call_scope_qn_matches_def_qn); + RUN_TEST(elixir_usage_records_receiver); RUN_TEST(elixir_call_string_argument); RUN_TEST(extract_elixir_guarded_def_head_scope); RUN_TEST(extract_elixir_guarded_def_head_guard_usages); From 9b10ba281cfdba2676e9d24c445470c4b6f7d4e3 Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:23 -0700 Subject: [PATCH 13/13] fix(mcp): answer a bare name with every arity of a function Two query paths read a bare function name, and neither could see an arity fence. get_code_snippet returned one body. Before arity was part of identity, fetch/1, fetch/2 and fetch/3 were one node and it returned whichever survived the collision, with no ambiguity signal. Now they are three nodes, and a bare `fetch` answers that the name is ambiguous, with one suggestion per arity. pick_resolved_node gains `fold_arity`, and get_code_snippet passes false. trace_call_path wants the opposite. "Who calls fetch" wants the callers of every arity, and bfs_union_same_name already unions the seeds it is given, so it passes true and candidates whose QNs are equal once the fence is removed fold into one logical symbol, the same way a body-less `.d.ts` stub already folds into its implementation. Two genuinely different same-named functions live in different containers, so their stripped QNs still differ and they are still reported ambiguous. The flag gates both tallies, the top-score tie and the rival-definition count, because either one alone decides the answer. Honouring it in only one left ambiguity resting on whether two clauses happened to have the same line count. For the same reason a fenced QN counts as a rival definition even with a one-line span. The body-less rule exists for ambient declarations; a fence is minted only from a definition head the extractor actually saw, so `def size(x), do: byte_size(x)` is a whole definition that merely occupies one line. Judging it by span would go back to deciding the answer by how many lines a body happens to take up. cbm_store_find_nodes_by_qn_suffix gains a third alternative matching the fenced forms of the same dotted tail. Without it, get_code_snippet("fetch"), the bare-name convention every agent is told to use, reached no arity of an overloaded function. '#' is not a LIKE metacharacter, so it is matched literally. On a three-arity fixture, get_code_snippet("fetch") answered with one arity's body and status "ok" before this commit and answers status "ambiguous" with one suggestion per arity after it, whatever the relative body lengths. trace_call_path is deliberately unchanged: it keeps folding the arities into one union, which is why the two paths need a flag between them. tests/test_mcp.c: snippet_bare_name_every_arity_is_ambiguous_whatever_the_spans pins that the answer does not depend on relative body length, and snippet_arity_fold_still_traces_every_arity_under_one_bare_name pins the trace side of the same flag. tests/test_store_nodes.c: store_find_by_qn_suffix_matches_arity_fence pins the probe. Its content dependency is the module/arity identity commit, which mints the fenced QNs this commit teaches the MCP layer to read. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- src/mcp/mcp.c | 129 +++++++++++++++++++++++++---- src/store/store.c | 20 ++++- tests/test_mcp.c | 174 +++++++++++++++++++++++++++++++++++++++ tests/test_store_nodes.c | 44 ++++++++++ 4 files changed, 348 insertions(+), 19 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 75ff8e396c..358b7adf37 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -8393,34 +8393,86 @@ static long node_resolution_score(const cbm_node_t *n) { return label_rank * (long)RES_LABEL_WEIGHT + span; } -/* A "real" callable definition: a Function/Method node with a non-empty body - * span (end_line > start_line). A body-less node (start_line == end_line) is an - * ambient declaration / signature stub — e.g. a TypeScript `.d.ts` declaration - * — which is a *fragment* of one logical symbol, not a distinct definition. The - * distinction lets pick_resolved_node union a stub with its real implementation - * (#546) while still treating two genuinely-different same-named functions as - * ambiguous rather than conflating their caller sets. */ -static bool node_is_real_callable_def(const cbm_node_t *n) { +/* Copy a QN without its '#' arity fence (internal/cbm/helpers.c). + * Declared extern rather than via helpers.h, which would pull the extraction + * layer's header into the MCP TU — the pattern used for + * cbm_kind_in_set_free_cache below. */ +extern size_t cbm_qn_strip_arity(char *out, size_t cap, const char *qn); + +/* True when `qn` ends in an all-digit '#' fence ("...fetch#3"). A fence is + * minted only from a definition head the extractor actually saw, which is what + * makes it evidence below. Rust's cfg twin ("add#cfg(test)") is not one. */ +static bool qn_has_arity_fence(const char *qn) { + if (!qn) { + return false; + } + const char *hash = strrchr(qn, '#'); + if (!hash || !hash[SKIP_ONE]) { + return false; + } + for (const char *p = hash + SKIP_ONE; *p; p++) { + if (*p < '0' || *p > '9') { + return false; + } + } + return true; +} + +/* Does this candidate count as a RIVAL definition of the name — a distinct + * implementation rather than a fragment of one logical symbol? + * + * A Function/Method node with a non-empty body span (end_line > start_line) is + * one. A body-less node (start_line == end_line) normally is not: it is an + * ambient declaration / signature stub — a TypeScript `.d.ts` declaration — + * which is why pick_resolved_node may union it with its real implementation + * (#546/#650) while still reporting two genuinely-different same-named + * functions as ambiguous rather than conflating their caller sets. + * + * An arity-fenced QN is the exception, and only where arities are being counted + * separately (fold_arity == false). The fence says the extractor saw a whole + * definition head, so `def size(x), do: byte_size(x)` — a complete one-line + * Elixir clause — is a real definition that merely occupies one line. Judging it + * by span would put the answer straight back where the flag took it from: + * decided by how many lines a body happens to take up. */ +static bool node_is_rival_def(const cbm_node_t *n, bool fold_arity) { if (!n->label) { return false; } if (strcmp(n->label, "Function") != 0 && strcmp(n->label, "Method") != 0) { return false; } - return (long)n->end_line - (long)n->start_line > 0; + if ((long)n->end_line - (long)n->start_line > 0) { + return true; + } + return !fold_arity && qn_has_arity_fence(n->qualified_name); } /* Pick the best-resolving node among name matches. Sets *ambiguous when the * matches can't be reduced to one logical symbol, so resolution never silently * traces (or conflates) the wrong same-named node: * 1. the top score is shared by >1 candidate (a genuine rank/span tie), or - * 2. two or more *real* callable definitions share the name — distinct + * 2. two or more RIVAL definitions share the name (node_is_rival_def) — distinct * implementations, not a definition plus its body-less stub(s). * Rule 2 completes rule 1: without it, two same-named functions whose bodies * differ in length score differently, dodge the tie, and get their caller sets * unioned by bfs_union_same_name (#546) into one confidently-conflated answer. * Body-less .d.ts stubs still union with their implementation (#650). */ -static int pick_resolved_node(const cbm_node_t *nodes, int count, bool *ambiguous) { +/* `fold_arity` says whether candidates that differ ONLY by their QN's arity + * fence count as one logical symbol. It gates BOTH tallies -- the top-score tie + * and the real-definition count -- because either one alone decides the answer, + * and a flag honoured by only one of them makes the outcome depend on the other: + * folding real definitions unconditionally left ambiguity resting on whether two + * clauses happened to score equally, i.e. on whether their bodies happened to + * have the same line count. + * + * trace_path passes true: "who calls fetch" wants the callers of every arity, + * and bfs_union_same_name already unions the seeds it is given. get_code_snippet + * passes false: it returns ONE body, and fetch/1 and fetch/3 have different + * bodies, so listing all three fenced QNs as suggestions is the honest answer -- + * the answer it used to give was whichever arity's node survived the collision, + * with no ambiguity signal at all. */ +static int pick_resolved_node(const cbm_node_t *nodes, int count, bool fold_arity, + bool *ambiguous) { *ambiguous = false; if (count <= 1) { return 0; @@ -8436,11 +8488,58 @@ static int pick_resolved_node(const cbm_node_t *nodes, int count, bool *ambiguou } int top_count = 0; int real_def_count = 0; + /* With fold_arity, a tie between two arities of one function is not a tie + * at all: the seeds get unioned, so which one ranks first is immaterial. */ + const char *top_stripped_of = NULL; + char top_stripped[CBM_SZ_512]; + char top_probe[CBM_SZ_512]; + /* Under fold_arity, arities of one function are ONE logical symbol. Elixir's + * fetch/1 and fetch/3 are distinct NODES (that is the whole point of the + * arity fence) but they are the same name in the same container, and a + * caller that unions its seeds wants all of them, so two candidates whose + * QNs are equal once the fence is removed are folded here, the same way a + * .d.ts stub folds into its implementation above. Two genuinely different + * same-named functions live in different containers, so their stripped QNs + * still differ and they are still reported ambiguous. + * + * Without fold_arity the fold does not happen at all: each arity is counted, + * so a bare name naming several of them is ambiguous and gets one suggestion + * per arity. That is the honest answer for a caller returning a single body, + * and it is a deliberate difference from the folding branch, not an accident + * of which arity happened to rank first. */ + const char *first_stripped_of = NULL; + char first_stripped[CBM_SZ_512]; + char stripped[CBM_SZ_512]; for (int i = 0; i < count; i++) { if (node_resolution_score(&nodes[i]) == best_score) { - top_count++; + if (!fold_arity) { + top_count++; + } else { + cbm_qn_strip_arity(top_probe, sizeof(top_probe), nodes[i].qualified_name); + if (!top_stripped_of) { + (void)snprintf(top_stripped, sizeof(top_stripped), "%s", top_probe); + top_stripped_of = top_stripped; + top_count++; + } else if (strcmp(top_stripped, top_probe) != 0) { + top_count++; + } + } } - if (node_is_real_callable_def(&nodes[i])) { + if (!node_is_rival_def(&nodes[i], fold_arity)) { + continue; + } + if (!fold_arity) { + /* Every arity is its own definition with its own body, so a caller + * that returns one body must be told there is more than one. */ + real_def_count++; + continue; + } + cbm_qn_strip_arity(stripped, sizeof(stripped), nodes[i].qualified_name); + if (!first_stripped_of) { + (void)snprintf(first_stripped, sizeof(first_stripped), "%s", stripped); + first_stripped_of = first_stripped; + real_def_count++; + } else if (strcmp(first_stripped, stripped) != 0) { real_def_count++; } } @@ -9188,7 +9287,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { * ambiguity (rather than silently tracing nodes[0]) on a genuine tie — e.g. * a C main() vs a same-named shell-script main(). */ bool trace_ambiguous = false; - int sel = pick_resolved_node(nodes, node_count, &trace_ambiguous); + int sel = pick_resolved_node(nodes, node_count, true, &trace_ambiguous); if (trace_ambiguous) { char *result = snippet_suggestions(func_name, nodes, node_count); free(func_name); @@ -12456,7 +12555,7 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { * instead of forcing a disambiguation round trip; only a genuine tie still * returns suggestions. */ bool snip_ambiguous = false; - int ssel = pick_resolved_node(suffix_nodes, suffix_count, &snip_ambiguous); + int ssel = pick_resolved_node(suffix_nodes, suffix_count, false, &snip_ambiguous); if (!snip_ambiguous) { copy_node(&suffix_nodes[ssel], &node); cbm_store_free_nodes(suffix_nodes, suffix_count); diff --git a/src/store/store.c b/src/store/store.c index fa074cb5db..8eb42db587 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -4383,17 +4383,27 @@ int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const if (!s || !s->db) { return CBM_STORE_ERR; } - /* Match QNs ending with ".suffix" or exactly equal to suffix */ + /* Match QNs ending with ".suffix" or exactly equal to suffix. + * + * A QN may carry an arity fence ("...Store.fetch#3"), so a third + * alternative matches the fenced forms of the same dotted tail. Without it + * get_code_snippet("fetch") -- the bare-name convention every agent is + * told to use -- reaches no arity of an overloaded function at all. The + * '#' is matched literally: it is not a LIKE metacharacter. */ char like_pattern[CBM_SZ_512]; - snprintf(like_pattern, sizeof(like_pattern), "%%.%s", suffix); + (void)snprintf(like_pattern, sizeof(like_pattern), "%%.%s", suffix); + char arity_pattern[CBM_SZ_512]; + (void)snprintf(arity_pattern, sizeof(arity_pattern), "%%.%s#%%", suffix); const char *sql_with_project = "SELECT id, project, label, name, qualified_name, file_path, " "start_line, end_line, properties FROM nodes " - "WHERE project = ?1 AND (qualified_name LIKE ?2 OR qualified_name = ?3)"; + "WHERE project = ?1 AND (qualified_name LIKE ?2 OR qualified_name = ?3 " + "OR qualified_name LIKE ?4)"; const char *sql_any = "SELECT id, project, label, name, qualified_name, file_path, " "start_line, end_line, properties FROM nodes " - "WHERE (qualified_name LIKE ?1 OR qualified_name = ?2)"; + "WHERE (qualified_name LIKE ?1 OR qualified_name = ?2 " + "OR qualified_name LIKE ?3)"; sqlite3_stmt *stmt = NULL; int rc = @@ -4407,9 +4417,11 @@ int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const bind_text(stmt, SKIP_ONE, project); bind_text(stmt, ST_COL_2, like_pattern); bind_text(stmt, ST_COL_3, suffix); + bind_text(stmt, ST_COL_4, arity_pattern); } else { bind_text(stmt, SKIP_ONE, like_pattern); bind_text(stmt, ST_COL_2, suffix); + bind_text(stmt, ST_COL_3, arity_pattern); } int cap = ST_INIT_CAP_8; diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ffa9ea3a3b..a9bdbf6c8e 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -16312,6 +16312,178 @@ TEST(snippet_exact_qn) { PASS(); } +/* Reproduce-first: get_code_snippet returns exactly ONE body, so a bare name + * that names several arities of one function has no single honest answer -- it + * must be reported ambiguous, with one suggestion per arity. + * + * Before the fold_arity gate reached the real-definition tally, the arity fence + * was stripped there UNCONDITIONALLY, so every arity of one function folded into + * a single "real definition" and ambiguity could only still arrive through the + * top-score tie -- that is, only when two clauses happened to have the same body + * line count. On the real Elixir corpus that silently answered 145 of 223 + * multi-arity bare names with whichever arity had the longest body. + * + * The three fixtures below differ ONLY in that incidental line count: `ne` has + * unequal spans (2 and 3), `eq` has equal spans (2 and 2), and `oneline` pairs a + * complete one-line Elixir clause (`def oneline(a), do: a`, span 0) with a + * multi-line one. RED before the fix for `ne` and for `oneline` (a plain snippet + * response, no status, no suggestions); `eq` was already ambiguous by accident + * and must stay so. Span is not identity. + * + * `oneline` is the second half of the same defect: a span-0 node is normally an + * ambient declaration stub that folds into its real implementation (#650), but a + * one-line `def` is a whole definition, and its arity fence proves the extractor + * saw the head. Without the fence exception in node_is_rival_def the answer is + * still decided by a line count -- just a different one. */ +TEST(snippet_bare_name_every_arity_is_ambiguous_whatever_the_spans) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "arity-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/arity"); + + /* ne/1 and ne/2: DIFFERENT body spans, so they cannot tie on score. */ + cbm_node_t ne1 = {.project = proj, + .label = "Function", + .name = "ne", + .qualified_name = "arity-proj.lib.tie.Fx.Tie.ne#1", + .file_path = "lib/tie.ex", + .start_line = 10, + .end_line = 12}; + cbm_node_t ne2 = {.project = proj, + .label = "Function", + .name = "ne", + .qualified_name = "arity-proj.lib.tie.Fx.Tie.ne#2", + .file_path = "lib/tie.ex", + .start_line = 14, + .end_line = 17}; + /* eq/1 and eq/2: EQUAL body spans, the accidental tie that used to be the + * only way a multi-arity name reached the ambiguous answer. */ + cbm_node_t eq1 = {.project = proj, + .label = "Function", + .name = "eq", + .qualified_name = "arity-proj.lib.tie.Fx.Tie.eq#1", + .file_path = "lib/tie.ex", + .start_line = 2, + .end_line = 4}; + cbm_node_t eq2 = {.project = proj, + .label = "Function", + .name = "eq", + .qualified_name = "arity-proj.lib.tie.Fx.Tie.eq#2", + .file_path = "lib/tie.ex", + .start_line = 6, + .end_line = 8}; + ASSERT_GT(cbm_store_upsert_node(st, &ne1), 0); + ASSERT_GT(cbm_store_upsert_node(st, &ne2), 0); + /* oneline/1 is a complete clause written on one line: span 0, which the + * body-less-stub rule would otherwise discount entirely. */ + cbm_node_t one1 = {.project = proj, + .label = "Function", + .name = "oneline", + .qualified_name = "arity-proj.lib.tie.Fx.Tie.oneline#1", + .file_path = "lib/tie.ex", + .start_line = 20, + .end_line = 20}; + cbm_node_t one2 = {.project = proj, + .label = "Function", + .name = "oneline", + .qualified_name = "arity-proj.lib.tie.Fx.Tie.oneline#2", + .file_path = "lib/tie.ex", + .start_line = 22, + .end_line = 26}; + ASSERT_GT(cbm_store_upsert_node(st, &eq1), 0); + ASSERT_GT(cbm_store_upsert_node(st, &eq2), 0); + ASSERT_GT(cbm_store_upsert_node(st, &one1), 0); + ASSERT_GT(cbm_store_upsert_node(st, &one2), 0); + + char *ne_resp = call_snippet(srv, "{\"qualified_name\":\"ne\",\"project\":\"arity-proj\"}"); + ASSERT_NOT_NULL(ne_resp); + ASSERT_NOT_NULL(strstr(ne_resp, "\"status\":\"ambiguous\"")); + ASSERT_NOT_NULL(strstr(ne_resp, "Fx.Tie.ne#1")); + ASSERT_NOT_NULL(strstr(ne_resp, "Fx.Tie.ne#2")); + /* No body was handed back in place of the ambiguity. */ + ASSERT_NULL(strstr(ne_resp, "\"source\"")); + free(ne_resp); + + char *eq_resp = call_snippet(srv, "{\"qualified_name\":\"eq\",\"project\":\"arity-proj\"}"); + ASSERT_NOT_NULL(eq_resp); + ASSERT_NOT_NULL(strstr(eq_resp, "\"status\":\"ambiguous\"")); + ASSERT_NOT_NULL(strstr(eq_resp, "Fx.Tie.eq#1")); + ASSERT_NOT_NULL(strstr(eq_resp, "Fx.Tie.eq#2")); + ASSERT_NULL(strstr(eq_resp, "\"source\"")); + free(eq_resp); + + char *one_resp = + call_snippet(srv, "{\"qualified_name\":\"oneline\",\"project\":\"arity-proj\"}"); + ASSERT_NOT_NULL(one_resp); + ASSERT_NOT_NULL(strstr(one_resp, "\"status\":\"ambiguous\"")); + ASSERT_NOT_NULL(strstr(one_resp, "Fx.Tie.oneline#1")); + ASSERT_NOT_NULL(strstr(one_resp, "Fx.Tie.oneline#2")); + ASSERT_NULL(strstr(one_resp, "\"source\"")); + free(one_resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* The other half of the same flag: trace_path asks "who calls ne", which wants + * the callers of EVERY arity, so there it folds and answers. Same fixture, same + * unequal spans, opposite verdict -- which is what makes the get_code_snippet + * answer above a deliberate difference rather than a tightening that leaked. */ +TEST(snippet_arity_fold_still_traces_every_arity_under_one_bare_name) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "fold-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/fold"); + + cbm_node_t ne1 = {.project = proj, + .label = "Function", + .name = "ne", + .qualified_name = "fold-proj.lib.tie.Fx.Tie.ne#1", + .file_path = "lib/tie.ex", + .start_line = 10, + .end_line = 12}; + cbm_node_t ne2 = {.project = proj, + .label = "Function", + .name = "ne", + .qualified_name = "fold-proj.lib.tie.Fx.Tie.ne#2", + .file_path = "lib/tie.ex", + .start_line = 14, + .end_line = 17}; + cbm_node_t caller = {.project = proj, + .label = "Function", + .name = "invoke", + .qualified_name = "fold-proj.lib.caller.Fx.Caller.invoke#1", + .file_path = "lib/caller.ex", + .start_line = 2, + .end_line = 4}; + int64_t id_ne1 = cbm_store_upsert_node(st, &ne1); + int64_t id_ne2 = cbm_store_upsert_node(st, &ne2); + int64_t id_caller = cbm_store_upsert_node(st, &caller); + ASSERT_GT(id_ne1, 0); + ASSERT_GT(id_ne2, 0); + ASSERT_GT(id_caller, 0); + cbm_edge_t e1 = {.project = proj, .source_id = id_caller, .target_id = id_ne1, .type = "CALLS"}; + cbm_edge_t e2 = {.project = proj, .source_id = id_caller, .target_id = id_ne2, .type = "CALLS"}; + cbm_store_insert_edge(st, &e1); + cbm_store_insert_edge(st, &e2); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"ne\"," + "\"project\":\"fold-proj\",\"direction\":\"inbound\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NULL(strstr(inner, "ambiguous")); + ASSERT_NOT_NULL(strstr(inner, "invoke")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + /* ── TestSnippet_QNSuffix ─────────────────────────────────────── */ TEST(snippet_qn_suffix) { @@ -20740,6 +20912,8 @@ SUITE(mcp) { /* Snippet resolution (port of snippet_test.go) */ RUN_TEST(snippet_exact_qn); + RUN_TEST(snippet_bare_name_every_arity_is_ambiguous_whatever_the_spans); + RUN_TEST(snippet_arity_fold_still_traces_every_arity_under_one_bare_name); RUN_TEST(snippet_qn_suffix); RUN_TEST(snippet_unique_short_name); RUN_TEST(snippet_name_tier); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index cf7b8bc8fd..10e3138af1 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -1113,6 +1113,49 @@ TEST(store_find_by_qn_suffix_dot_boundary) { PASS(); } +/* An arity-fenced QN ("...fetch#3") must still be reachable by the DOTTED tail + * a caller writes. get_code_snippet resolves a short name through this probe, + * and the bare symbol name with no arity suffix is the calling convention + * agents are given — so without the fenced alternative an overloaded function + * would be unreachable by every name an agent knows. */ +TEST(store_find_by_qn_suffix_matches_arity_fence) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t n1 = {.project = "test", + .label = "Function", + .name = "fetch", + .qualified_name = "test.lib.store.Fx.Store.fetch#1"}; + cbm_node_t n2 = {.project = "test", + .label = "Function", + .name = "fetch", + .qualified_name = "test.lib.store.Fx.Store.fetch#3"}; + cbm_node_t n3 = {.project = "test", + .label = "Function", + .name = "prefetch", + .qualified_name = "test.lib.store.Fx.Store.prefetch#1"}; + cbm_store_upsert_node(s, &n1); + cbm_store_upsert_node(s, &n2); + cbm_store_upsert_node(s, &n3); + + cbm_node_t *nodes = NULL; + int count = 0; + int rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "fetch", &nodes, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + /* Both arities, and NOT prefetch — the dot boundary still holds. */ + ASSERT_EQ(count, 2); + cbm_store_free_nodes(nodes, count); + + /* A container-qualified tail reaches the fenced forms too. */ + rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "Fx.Store.fetch", &nodes, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 2); + cbm_store_free_nodes(nodes, count); + + cbm_store_close(s); + PASS(); +} + /* ── Node degree ───────────────────────────────────────────────── */ TEST(store_node_degree) { @@ -2406,6 +2449,7 @@ SUITE(store_nodes) { RUN_TEST(store_find_by_file_overlap); RUN_TEST(store_find_by_qn_suffix_single); RUN_TEST(store_find_by_qn_suffix_no_match); + RUN_TEST(store_find_by_qn_suffix_matches_arity_fence); RUN_TEST(store_find_by_qn_suffix_multiple); RUN_TEST(store_find_by_qn_suffix_dot_boundary); RUN_TEST(store_node_degree);