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/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_calls.c b/internal/cbm/extract_calls.c index e1de896b8c..363a2e1cf2 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -708,9 +708,73 @@ 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. 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. + * 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. + * + * 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 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 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 + * 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 + * (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; } @@ -725,7 +789,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; } @@ -747,7 +811,13 @@ 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. 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 916f1136f9..e294b4a4b0 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5399,90 +5399,276 @@ 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); +// Fold one more clause of an Elixir function into the def already pushed for +// 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 body block. `scope` is the block the previous clause was written in, +// 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 +// 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 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` 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; } - return args; + 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 - 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 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. */ + 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. -static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro) { +// +// `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, + const char *module_qn, TSNode *scope) { CBMArena *a = ctx->arena; - TSNode args = elixir_call_args(node); - if (ts_node_is_null(args)) { + int arity = 0; + const char *name = cbm_elixir_def_head(ctx, node, &arity); + if (!name) { return; } - TSNode first_arg = ts_node_child(args, 0); - if (ts_node_is_null(first_arg)) { - return; - } - - 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]) { + 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); + 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.parent_class = module_qn; + def.param_count = arity; 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); } -// 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++; +} + +/* 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) { (void)spec; - TSNodeStack stack; - ts_nstack_init(&stack, ctx, CBM_SZ_64); - ts_nstack_push(&stack, node); + /* 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}; + 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) { @@ -5498,19 +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) { - extract_elixir_func_def(ctx, cur, macro); + 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) { - 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_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_unified.c b/internal/cbm/extract_unified.c index 086f501a0c..f6079b80ac 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -580,39 +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; } - 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 @@ -975,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` @@ -2036,6 +2029,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 @@ -2386,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); @@ -2626,7 +2739,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) -- @@ -2656,7 +2770,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/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index f6b3913151..3982dbdeea 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")); @@ -617,6 +622,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; @@ -2616,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))) { @@ -2718,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/helpers.c b/internal/cbm/helpers.c index 50c75bdf2e..1e0a973054 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"), ...) @@ -1184,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; @@ -1636,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 905de04a8d..ecc0d02b33 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -103,6 +103,27 @@ 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 (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 +// 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, @@ -230,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/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/foundation/constants.h b/src/foundation/constants.h index 9af0d4001c..2711d15116 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,16 @@ 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]); + * 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 * function is documented as the single source of truth for type-like labels 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/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/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_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..778be375d8 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", "{}"); @@ -2896,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); @@ -3114,12 +3148,39 @@ 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; + } + /* 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(rc->registry, usage->ref_name, module_qn, 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 9738f7198d..1e8533d56c 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,45 @@ 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; + } + /* 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. + * + * 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_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/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/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/src/pipeline/registry.c b/src/pipeline/registry.c index c99c72d8d5..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 @@ -858,9 +882,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 +946,31 @@ 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; + 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. + * + * 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); } } @@ -1047,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(); @@ -1109,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 @@ -1120,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; + } } - return match; + if (arity_match_count == CBM_COUNT_ONE) { + return arity_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, @@ -1252,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) { @@ -1269,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])) { @@ -1307,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"). @@ -1341,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; } @@ -1353,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 @@ -1389,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 { @@ -1411,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/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/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 1e6506ed1a..7d1d8359ca 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 ───────────────────────────────────────────────────── */ @@ -89,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++) { @@ -1246,6 +1289,836 @@ 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(); +} + +/* `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(); +} + +/* 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" + " 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 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. 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.Guards.unguarded_caller#1"), 1); + + /* 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 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" + " 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.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.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.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, 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 */ + " 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_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(); +} + +/* 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 */ + " 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")); + 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; + 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(); +} + +/* 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 + * 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 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 + * 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 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 */ + " 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")); + /* 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(); +} + +/* 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(); +} + +/* 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 @@ -1273,6 +2146,196 @@ 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(); +} + +/* 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. */ +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(); +} + +/* 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 -> " @@ -7931,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"); @@ -8258,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(); @@ -8387,7 +9520,28 @@ 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(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_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); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); RUN_TEST(erlang_function); 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_pipeline.c b/tests/test_pipeline.c index d32ab3538b..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"); @@ -1117,6 +1155,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. * @@ -15053,6 +15145,229 @@ 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; + * - three `def message/1` in three modules in one file collapsed to one; + * - 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); + 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")); + + /* 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(); +} + SUITE(pipeline) { RUN_TEST(pipeline_lsp_surface_persisted_and_body_edit_invariant); /* Index lock */ @@ -15098,7 +15413,11 @@ 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_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); RUN_TEST(pipeline_objectscript_export_aggregate_exceeds_arena_block_table); diff --git a/tests/test_registry.c b/tests/test_registry.c index 5f751a8993..54b45388ca 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -325,6 +325,209 @@ 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(); +} + +/* 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"); @@ -1201,6 +1404,13 @@ 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_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); 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);