diff --git a/README.md b/README.md index d997cbef8..c4428e067 100644 --- a/README.md +++ b/README.md @@ -906,7 +906,7 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a |------|-------------| | `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected (under `daemon start`, also after it disconnects), subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). | | `list_projects` | List indexed projects with paging; use `detail: "stats"` for node/edge counts and database sizes. | -| `delete_project` | Remove a project and all its graph data. | +| `delete_project` | Remove a project and all its graph data, including its ADR sidecar (`.adr.db`). | | `index_status` | Check indexing status of a project. | | `check_index_coverage` | Check whether exact paths or a scope are indexed and fresh. A clean result means no recorded gap, not proof of completeness. | diff --git a/graph-ui/package-lock.json b/graph-ui/package-lock.json index 794e775c0..c20a98c36 100644 --- a/graph-ui/package-lock.json +++ b/graph-ui/package-lock.json @@ -32,7 +32,7 @@ "playwright": "1.62.1", "three-stdlib": "2.36.1", "typescript": "5.9.3", - "vite": "6.4.4", + "vite": "6.4.5", "vitest": "4.1.12" } }, @@ -5277,9 +5277,9 @@ } }, "node_modules/vite": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", - "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/graph-ui/package.json b/graph-ui/package.json index 7d3b542b8..0fcf6ab3e 100644 --- a/graph-ui/package.json +++ b/graph-ui/package.json @@ -62,7 +62,7 @@ "playwright": "1.62.1", "three-stdlib": "2.36.1", "typescript": "5.9.3", - "vite": "6.4.4", + "vite": "6.4.5", "vitest": "4.1.12" }, "overrides": { diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 155f08dfa..979af928c 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -718,6 +718,66 @@ static bool elixir_call_head_in(TSNode call, const char *source, const char *con call_node_text_in(ts_node_child(call, 0), source, heads); } +/* A definition's head declares a name; it is never a call to it. The head under + * a guard is not the def's first argument -- `def f(x) when g` parses its whole + * head as a `when` binary_operator -- so the plain node comparison below stopped + * recognising it, and the inner `f(x)` was recorded as a call to `f`. Under the + * widened span that phantom lands inside the function's own node, and cbm.c + * decides self-recursion by line containment, so an ordinary two-clause guarded + * function reported itself recursive. `recursive` is a queryable node property + * and seeds the cycle detection in pass_complexity, so that is a load-bearing + * signal, not a cosmetic one. The suppression therefore travels with the fold + * rather than following it. + * + * The head comes from cbm_elixir_def_head_is (helpers.c) rather than a private + * peel here, because three files read this same node and all three have to + * agree on which node is the head, or a node one of them is treating as a + * definition name is recorded by another as a reference to that very name: + * - extract_defs.c, extract_elixir_func_def, unwraps it to NAME the + * definition. It and this file disagreeing mints the phantom above. + * - extract_usages.c, is_elixir_def_binding, treats an identifier inside that + * head as a binding occurrence rather than a reference, which is what keeps + * a head suppressed here from re-emerging as a USAGE edge: a suppression in + * one extractor of the unified walk only REMOVES a phantom if the others + * also decline the node. The two files ask different questions of the same + * helper -- this one whether the node IS the head or a `when` wrapper above + * it, that one whether the node sits INSIDE the head -- so there is one + * definition of the head and they cannot drift apart. That containment + * stops at the head rather than covering the whole `when` operator, which + * is deliberate in the other direction: only the parameters being BOUND are + * excluded, and a parameter READ in the guard is a reference, exactly as + * the same read in the body is. + * - extract_unified.c, compute_elixir_func_qn, resolves a def's QN to open + * the function's call scope and reads the head through the same helper. + * Handed the `when` operator instead it returns NULL, no scope is pushed, + * and every call in a guarded body is sourced from the File rather than + * from the enclosing Function. That failure is independent of the two above + * because it decides edge SOURCE, not edge existence. + * + * Suppressing the head does not only delete edges; it moves metadata on edges + * that SURVIVE, and that is worth stating because a caller reading an edge's + * `line` will see a different number than before. `edges` is UNIQUE on + * (source, target, type), so where the def-head phantom happened to be the + * dedup survivor for a pair, deleting it promotes some other row for that same + * pair and the surviving edge takes that row's `line`. + * + * Measured by indexing one 970-file Elixir lib tree with the binary built from + * this commit's parent and with this one, and diffing the `edges` rows out of + * the two SQLite stores keyed on (source QN, target QN, type): 2,436 edges go + * and NONE is added -- 2,104 CALLS and 332 USAGE. Of the 119,696 edges that + * survive, 1,054 record a different `line`. + * + * The typespec attributes -- `@spec f(t) :: u` and the @callback / @type family + * -- put the declared name in a `call` node too, and are deliberately NOT + * suppressed here. A def head can be, because extract_usages.c already treats a + * def's first argument as a binding. Nothing there treats a typespec subject as + * one, so declining it in this walk would not remove its phantom, it would + * RELABEL it: handle_usages reaches the bare identifier handle_calls just + * declined and mints a USAGE onto the same function, which under + * UNIQUE(source, target, type) is a separate row that no longer collides with + * the real call it used to hide behind, and which pass_importance then counts. + * A typespec phantom has to be removed before any extractor sees it, by + * skipping the whole subtree in the unified walk. */ static bool elixir_call_is_definition_role(TSNode node, const char *source) { static const char *const structural_heads[] = {"def", "defp", "defmacro", "defmodule", NULL}; static const char *const function_heads[] = {"def", "defp", "defmacro", NULL}; @@ -735,7 +795,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; } @@ -757,7 +817,23 @@ static bool call_node_is_definition_container(CBMLanguage lang, TSNode node, con if (lang == CBM_LANG_AGDA && strcmp(kind, "expr") == 0) { return agda_expr_is_definition_role(node); } - return lang == CBM_LANG_ELIXIR && strcmp(kind, "call") == 0 && + /* A guarded head is a `when` binary_operator, not a `call`, and Elixir's + * call node types include binary_operator -- so the head reaches this walk + * and must be able to answer that it is a definition. A paren-less clause + * (`def f when g`) has no inner call at all, so the operator node itself + * reaches extract_callee_name and, with no callee of its own, takes that + * function's last resort -- the first identifier child -- minting a phantom + * CALLS edge onto the very function being defined. + * + * That last resort overrides a deliberate NULL for every Elixir + * binary_operator extract_scripting_callee declines (`=`, `<-`, `->`, `\\`, + * `::`, `when`), so `def g(c \\ 2)` still emits a phantom `c` and + * `e = target(d)` a phantom `e`. That is the pre-existing behaviour for + * those operators and is untouched here: only a `when` operator is + * admitted, so an operator definition's own head (`def a + b`) stays an + * ordinary node, as it was before guards were handled at all. */ + return lang == CBM_LANG_ELIXIR && + (strcmp(kind, "call") == 0 || cbm_elixir_is_when_guard(node)) && elixir_call_is_definition_role(node, source); } diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 51cf7279f..3a476e4b8 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9714,8 +9714,119 @@ static TSNode elixir_call_args(TSNode node) { return args; } -// Handle Elixir def/defp/defmacro — extract function definition. -static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro) { +// Fold one more clause of an Elixir function into the def already pushed for +// the previous clause. Every clause is its own `def` call, and an Elixir QN +// carries neither module nor arity, so all of them compute one qualified name +// and collide on a single graph node. cbm_gbuf_upsert_node breaks a same-QN +// collision by keeping the LARGEST start_line, so the survivor was the last +// clause in the file and get_code_snippet returned that clause as the whole +// function: `def admin?(%__MODULE__{role: :admin}), do: true` / +// `def admin?(%__MODULE__{}), do: false` read back as a predicate that always +// returns false. One node per function is right, so widen its span instead. +// +// Two bounds keep the widened span honest, and a clause folds only if it clears +// both: +// - Same module body. `scope` is the do_block the previous clause was written +// in, so a nested `defmodule` cannot merge with its parent: the QN carries +// no module, so `Outer.run` and `Outer.Inner.run` compute the same name and +// are array-adjacent when Inner is declared just above Outer's own clause. +// - Same group. Only the immediately preceding EXTRACTED definition is a +// candidate, so a def of another name between two same-named defs ends the +// group. That is a bound this code imposes, not a guarantee Elixir gives: +// the compiler only WARNS ("clauses with the same name and arity should be +// grouped together"), it warns per name AND arity while this QN is +// arity-free, and non-contiguous clauses do compile. Split clauses +// therefore keep separate nodes, which is the pre-existing behaviour. +// What the group bound does NOT exclude is a non-definition construct +// between two clauses: `@doc` / `@spec` are unary_operators, `use` / +// `alias` / `describe` are ordinary calls, and a `defimpl` block's inner +// defs are not extracted -- none of them push a def, so anything of that +// kind written between two clauses of one function ends up inside the +// widened span, and that is not rare. Measured by indexing one 970-file +// Elixir lib tree with the binary built from this commit's parent and with +// this one and diffing the `nodes` rows out of the two SQLite stores: 6,996 +// Function nodes widen their span, none is added, none is lost and none +// narrows. Reading each widened span for a module-body construct written at +// the module's own two-space indent, which no clause body reaches, 169 of +// the 6,996 cover such a line. Counting each node once per kind, 123 cover +// a typespec attribute (@spec/@type/@typep/@opaque/@callback/ +// @macrocallback), 100 an @doc or @typedoc, 46 a bare comment line, 14 an +// @impl, and none a `use`, `alias`, `import`, `require` or a module-level +// `quote`. Swallowed typespecs are the largest attribute kind, not an +// absent one. +// +// The widened span also admits a phantom call that a narrower span kept out, +// which is why the head suppression in extract_calls.c travels with this fold +// rather than after it. cbm.c flags self-recursion by finding the innermost +// Function whose [start_line, end_line] contains a recorded call whose short +// name matches the function's own, so any pre-existing phantom between the +// first and the last clause becomes a self-edge as soon as the span covers it. +// That cost is real and is measured rather than asserted away. Same corpus, +// reading each flagged node's own source span for any line that names it as a +// call, a capture or a pipe target, counting a one-line clause's body after +// `, do:` as such a line: self_recursive Function nodes go 129 -> 430, and the +// ones carrying no self-call form anywhere in their span go 6 -> 48. The lines +// responsible are the typespec heads the widened span now covers -- `@spec +// f(t) :: u` puts the declared name in a `call` node, and the unified walk +// reads it as code -- a separate defect with a separate fix, not something this +// fold can close. +// +// Skipping the typespec subtrees, later in this stack, closes that half: the +// same method then reports 389 self_recursive Function nodes and 8 with no +// self-call form, against 129 and 6 on the base. All 8 are the pre-existing +// phantom a local variable sharing the function's name mints (`defp slug(value) +// do slug = ...`), 6 of them already present on the base; the other 2 are +// guarded clauses that only have a node of their own to be flagged on because +// of this stack. +// +// What the fold and the head suppression together remove is 2,436 edges, none +// of them added back: see elixir_call_is_definition_role in extract_calls.c. +// +// A clause whose macro differs (`def` foo/1 beside `defp` foo/2) still folds, +// because the arity-free QN already puts both on one node; is_exported is then +// the OR over the folded clauses, so a name any clause exports stays exported. +// That OR changes a recorded flag, which the node diff confirms and which is +// worth stating rather than leaving to be discovered: on the same corpus +// exactly 6 Function nodes change is_exported, all false -> true and none the +// other way. Each is a `def` clause written above a `defp` clause of the same +// name, where the pre-patch last-clause-wins record reported a publicly +// callable name as unexported. Verified against the source in every one: the +// new value is the correct one. +// Returns true when the clause folded. +static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, TSNode node, + uint32_t end_line, bool is_exported) { + if (!qn || ctx->result->defs.count <= 0 || ts_node_is_null(scope)) { + return false; + } + TSNode here = ts_node_parent(node); + if (ts_node_is_null(here) || !ts_node_eq(here, scope)) { + return false; + } + CBMDefinition *prev = &ctx->result->defs.items[ctx->result->defs.count - 1]; + if (!prev->label || strcmp(prev->label, "Function") != 0 || !prev->qualified_name || + strcmp(prev->qualified_name, qn) != 0) { + return false; + } + /* Only end_line moves. prev->start_line is already the minimum, so this + * takes no start_line argument: a clause folds only into the def pushed + * for the immediately preceding extracted clause of the same do_block, and + * extract_elixir_call pushes a do_block's children onto its stack in + * reverse index order so they pop in source order. A `start_line < + * prev->start_line` guard here would be unreachable; if that traversal + * ever stops being source-ordered, this is the line that has to change + * with it. */ + if (end_line > prev->end_line) { + prev->end_line = end_line; + } + prev->is_exported = prev->is_exported || is_exported; + return true; +} + +// Handle Elixir def/defp/defmacro — extract function definition. `scope` is the +// module body the previous clause was extracted from, and is updated to this +// def's own module body; see fold_elixir_clause for what it bounds. +static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro, + TSNode *scope) { CBMArena *a = ctx->arena; TSNode args = elixir_call_args(node); if (ts_node_is_null(args)) { @@ -9727,6 +9838,16 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char return; } + // `def name(args) when guard` parses the whole head as a `when` + // binary_operator, so the name lives on its left operand rather than + // directly under the call. Without unwrapping it, every guarded clause is + // dropped, and a function whose clauses ALL carry guards never appears in + // the graph at all -- silently, since a missing definition is not an error. + // The unwrap lives in helpers.c because it peels only `when`: an operator + // definition (`def a + b`) is a binary_operator head too, and unwrapping + // that one would name the function after its own left parameter. + first_arg = cbm_elixir_def_head_unwrap_guard(first_arg); + const char *fk = ts_node_type(first_arg); char *name = NULL; if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) { @@ -9738,15 +9859,25 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char return; } + const char *qn = cbm_fqn_compute(a, ctx->project, ctx->rel_path, name); + uint32_t start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; + uint32_t end_line = ts_node_end_point(node).row + TS_LINE_OFFSET; + bool is_exported = (strcmp(macro, "def") == 0 || strcmp(macro, "defmacro") == 0); + bool folded = fold_elixir_clause(ctx, qn, *scope, node, end_line, is_exported); + *scope = ts_node_parent(node); + if (folded) { + return; + } + CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; - def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, name); + def.qualified_name = qn; def.label = "Function"; def.file_path = ctx->rel_path; - def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; - def.end_line = ts_node_end_point(node).row + TS_LINE_OFFSET; - def.is_exported = (strcmp(macro, "def") == 0 || strcmp(macro, "defmacro") == 0); + def.start_line = start_line; + def.end_line = end_line; + def.is_exported = is_exported; cbm_defs_push(&ctx->result->defs, a, def); } @@ -9783,6 +9914,10 @@ static TSNode emit_elixir_module_class(CBMExtractCtx *ctx, TSNode cur) { // without recursion between extract_elixir_call ↔ extract_elixir_module_def. static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { (void)spec; + /* Module body the last extracted clause was written in; see + * fold_elixir_clause. Null until the first def, so nothing folds into a + * def left over from a previous top-level call node. */ + TSNode def_scope = {0}; TSNodeStack stack; ts_nstack_init(&stack, ctx, CBM_SZ_64); ts_nstack_push(&stack, node); @@ -9805,12 +9940,17 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp if (strcmp(macro, "def") == 0 || strcmp(macro, "defp") == 0 || strcmp(macro, "defmacro") == 0) { - extract_elixir_func_def(ctx, cur, macro); + extract_elixir_func_def(ctx, cur, macro, &def_scope); } else if (strcmp(macro, "if") == 0 || strcmp(macro, "unless") == 0) { /* A compile-time if/unless in a module body defines functions * under a condition: its do and else branches hold variants of the * same definitions (graph_buffer.c, "Definition variants"). The - * grammar nests the else_block inside the do_block. */ + * grammar nests the else_block inside the do_block. + * The clause fold composes with this: fold_elixir_clause only + * folds a clause into one written in the same block, and each + * branch is its own block, so the clauses of one branch fold into + * one definition while the branches stay separate definitions that + * the graph buffer merges as variants of one identity. */ TSNode branch = cbm_find_child_by_kind(cur, "do_block"); for (int pass = 0; pass < 2 && !ts_node_is_null(branch); pass++) { uint32_t bc = ts_node_child_count(branch); diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index acada15ad..857a43ea1 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -603,6 +603,11 @@ static const char *compute_elixir_func_qn(CBMExtractCtx *ctx, TSNode node) { if (ts_node_is_null(first_arg)) { return NULL; } + /* A guard wraps the whole head in a `when` operator, so the head naming the + * function is its left operand. Left wrapped, this returns NULL and the def + * opens NO function scope: every call in a guarded body then sources to the + * FILE node and the function itself reports no outgoing edges. */ + first_arg = cbm_elixir_def_head_unwrap_guard(first_arg); const char *fk = ts_node_type(first_arg); char *name = NULL; if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) { @@ -2154,6 +2159,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 @@ -2755,7 +2865,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) -- @@ -2788,7 +2899,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 a009b7964..bd49b759b 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -617,6 +617,11 @@ static bool is_elixir_def_binding(CBMExtractCtx *ctx, TSNode node) { TSNode signature = ts_node_named_child_count(arguments) > 0 ? ts_node_named_child(arguments, 0) : arguments; + /* A guard makes that signature the whole `when` operator, which spans the + * guard expression as well as the head. Unwrapped to the head, only the + * parameters being BOUND are excluded here; a parameter READ inside the + * guard stays a usage, as the same read in the body already is. */ + signature = cbm_elixir_def_head_unwrap_guard(signature); return node_contains(head, node) || node_contains(signature, node); } return false; diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index d6a8e73b9..796a9a4ef 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1382,6 +1382,93 @@ const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, c return scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name; } +/* ── Elixir def-head guards ─────────────────────────────────── */ + +/* The guard sits on the head, not beside it: `def name(args) when guard` parses + * as a single `when` binary_operator whose left operand is `name(args)`. The + * operator field is an exact anonymous token, so the check is the token type + * rather than the node kind — `def a + b` is a binary_operator head too, and + * unwrapping it would name the function after its left parameter. */ +bool cbm_elixir_is_when_guard(TSNode node) { + if (ts_node_is_null(node) || strcmp(ts_node_type(node), "binary_operator") != 0) { + return false; + } + TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator")); + return !ts_node_is_null(op) && strcmp(ts_node_type(op), "when") == 0; +} + +/* One `when` level off a def's first argument, or the node unchanged. + * + * There is deliberately no named-child fallback for a missing `left`. An infix + * operator cannot reduce without a left operand, so tree-sitter-elixir never + * builds a `when` binary_operator that lacks one: measured over every guarded + * shape in the suite plus a corpus of truncated heads (`def when true`, + * `def f(x) when`, `def f(x) when when is_x(x)`, `def when() when when`), + * `left` was present on all 66 unwraps, ERROR-recovered parses included, and + * every one had the left operand as named child 0. Where the left operand was + * genuinely absent the grammar produced no `when` operator at all -- it read + * `when` as the head identifier -- so the unwrap is never entered. A + * named-child fallback would therefore be dead on a well-formed tree and, on + * the hypothetical malformed one, would hand back named child 0 without + * knowing it is the left operand rather than the guard expression: naming the + * function after its own guard. Returning the node unchanged degrades to the + * pre-guard behaviour instead, which is the safe direction. */ +static TSNode elixir_unwrap_one_guard(TSNode first_arg) { + if (!cbm_elixir_is_when_guard(first_arg)) { + return first_arg; + } + TSNode lhs = ts_node_child_by_field_name(first_arg, TS_FIELD("left")); + return ts_node_is_null(lhs) ? first_arg : lhs; +} + +/* The head under every guard a def's first argument carries, or first_arg + * unchanged when it carries none. Elixir admits more than one guard on a + * clause -- `def f(x) when is_atom(x) when is_binary(x)` -- and + * tree-sitter-elixir nests those RIGHT-associatively, measured on that exact + * source: `when(f(x), when(is_atom(x), is_binary(x)))`. The declared head is + * therefore the outer operator's left operand and one peel reaches it. The + * peel still runs to a fixed point: the extra iteration is one token compare, + * and it is what makes the helper's contract "the head under EVERY guard" + * rather than "the head under the first one", which is the property the three + * walks rely on. + * + * Left wrapped, the defs walk drops the clause, the unified walk opens no + * function scope (so every call in a guarded body sources to the FILE node), + * the calls walk stops recognising the head and emits it as an invocation of + * the very function being defined, and the usages walk swallows every + * identifier in the guard as part of the binding. Four walks read this same + * argument, which is why the unwrap lives here rather than in any one of + * them. */ +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg) { + for (;;) { + TSNode next = elixir_unwrap_one_guard(first_arg); + if (ts_node_eq(next, first_arg)) { + return first_arg; + } + first_arg = next; + } +} + +/* True when `node` is the head a def declares, or any of the `when` guard + * operators wrapping it. The calls walk needs the whole chain, not just its + * ends: each member reaches that walk by its own route -- `def f(x) when g` + * arrives as the inner `f(x)` call, `def f when g` has no inner call at all and + * the operator itself falls through to extract_callee_name's first-identifier + * last resort -- and a member it fails to recognise as a declaration is emitted + * as an invocation of the function being defined. */ +bool cbm_elixir_def_head_is(TSNode signature, TSNode node) { + for (;;) { + if (ts_node_eq(signature, node)) { + return true; + } + TSNode next = elixir_unwrap_one_guard(signature); + if (ts_node_eq(next, signature)) { + return false; + } + signature = next; + } +} + static const char *func_node_name(CBMArena *a, TSNode func_node, const char *source, CBMLanguage lang) { // Wolfram: set_delayed_top/set_top/set_delayed/set — LHS is apply(user_symbol("f"), ...) diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 4c96cb5f0..2e6b669fe 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -157,6 +157,26 @@ const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char // attrpath and an enclosing attrset compose into one qualified name. const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name); +// ── Elixir def-head guards ── +// `def f(x) when g` parses its WHOLE head as a `when` binary_operator, so the +// `f(x)` call naming the function is the operator's left operand, and more than +// one guard (`when a when b`) nests those operators right-associatively, so the +// head stays the outermost operator's left operand in either shape. Four +// walks read that same first argument — the defs walk names the function from +// it, the unified walk opens the function's call scope from it, the calls walk +// tells a head apart from an invocation by it, and the usages walk excludes the +// head's identifiers from the usage set by it — so a private copy in one of +// them makes the rest disagree about what a guarded clause is. +// cbm_elixir_is_when_guard answers "is this node a guarded head"; the unwrap +// returns the head under every guard, or the node unchanged when it carries +// none; cbm_elixir_def_head_is answers whether a node is that head or one of +// the guard operators above it, which is the question the calls walk asks. +// All three test the operator token, so an operator definition (`def a + b`) is +// left alone rather than mis-read as a guard. +bool cbm_elixir_is_when_guard(TSNode node); +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg); +bool cbm_elixir_def_head_is(TSNode signature, TSNode node); + // Resolve a function/method definition node's NAME node across all ~130 grammars // (generic `name` field, arrow→declarator, C/C++ declarator chain, plus the many // per-language quirks: Fortran subroutine, SCSS mixin, SQL create_function, R, diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 503d18538..ee9c6d5f9 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -46,6 +46,25 @@ static const lib_pattern_t http_libraries[] = { * case-sensitive, so without this entry `Axios.get(url)` fell through to * the `.get` route-suffix fallback and became a route registration. */ {"Axios", CBM_SVC_HTTP, NULL}, + /* Angular HttpClient (#2768). The receiver rows end in a dot, like "gin.": + * a bare "this.http" / "$http" would also match this.httpAdapter.get + * (NestJS route registration) and $httpBackend.expectGET (an AngularJS test + * double), because an uppercase letter after an id counts as CamelCase + * glue. A chain wrapped before the verb keeps its line break in the callee + * text ("this.http\n .post", prettier's style), so each receiver also + * has a line-break twin: LF, and CR for CRLF files. Java/C# HttpClient is + * already listed below. */ + {"@angular/common/http", CBM_SVC_HTTP, NULL}, + {"angular.common.http", CBM_SVC_HTTP, NULL}, + {"this.http.", CBM_SVC_HTTP, NULL}, + {"this.http\n", CBM_SVC_HTTP, NULL}, + {"this.http\r", CBM_SVC_HTTP, NULL}, + {"httpClient.", CBM_SVC_HTTP, NULL}, + {"httpClient\n", CBM_SVC_HTTP, NULL}, + {"httpClient\r", CBM_SVC_HTTP, NULL}, + {"$http.", CBM_SVC_HTTP, NULL}, + {"$http\n", CBM_SVC_HTTP, NULL}, + {"$http\r", CBM_SVC_HTTP, NULL}, {"superagent", CBM_SVC_HTTP, NULL}, {"needle", CBM_SVC_HTTP, NULL}, {"node-fetch", CBM_SVC_HTTP, NULL}, diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index 6eeabfa44..d9f632e7c 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -74,9 +74,9 @@ src/pipeline/pass_semantic.c 3 src/pipeline/pass_tests.c 3 src/pipeline/pass_usages.c 4 src/pipeline/path_alias.c 40 -src/pipeline/pipeline.c 132 +src/pipeline/pipeline.c 129 src/pipeline/pipeline_delta.c 19 -src/pipeline/pipeline_incremental.c 140 +src/pipeline/pipeline_incremental.c 130 src/pipeline/registry.c 17 src/pipeline/worker_pool.c 2 src/store/store.c 354 diff --git a/src/cli/cli.c b/src/cli/cli.c index 00684c46d..bccc4f183 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -2720,7 +2720,26 @@ int cbm_remove_zed_mcp_owned(const char *binary_path, const char *config_path) { static bool dir_exists(const char *path) { cbm_path_info_t info = {0}; - return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_directory; + if (cbm_path_info_utf8(path, &info) != CBM_PATH_INFO_OK) { + return false; + } + if (info.is_directory) { + return true; + } +#ifndef _WIN32 + if (info.is_symlink) { + uid_t self = geteuid(); + struct stat link_state; + if (lstat(path, &link_state) != 0 || !S_ISLNK(link_state.st_mode) || + link_state.st_uid != self) { + return false; + } + struct stat target_state; + return stat(path, &target_state) == 0 && S_ISDIR(target_state.st_mode) && + target_state.st_uid == self; + } +#endif + return false; } /* Resolve the Claude Code config dir. @@ -7464,6 +7483,13 @@ static const char *get_cache_dir(const char *home_dir) { return cbm_resolve_cache_dir(); } +/* A per-project ADR sidecar (".adr.db") sits in the cache beside its index + * but is not itself an index — exclude it from listing/counting. */ +static bool cli_is_adr_sidecar(const char *name, size_t len) { + const size_t ext = sizeof(".adr.db") - 1; + return len >= ext && strcmp(name + len - ext, ".adr.db") == 0; +} + int cbm_list_indexes(const char *home_dir) { const char *cache_dir = get_cache_dir(home_dir); if (!cache_dir) { @@ -7478,6 +7504,7 @@ int cbm_list_indexes(const char *home_dir) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { + /* cbm_is_project_index_db already excludes the ".adr.db" sidecar. */ if (cbm_is_project_index_db(ent->name)) { printf(" %s/%s\n", cache_dir, ent->name); count++; @@ -7501,9 +7528,18 @@ int cbm_remove_indexes(const char *home_dir) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { - if (cbm_is_project_index_db(ent->name)) { - char path[CLI_BUF_1K]; - snprintf(path, sizeof(path), "%s/%s", cache_dir, ent->name); + bool is_index = cbm_is_project_index_db(ent->name); + /* A ".adr.db" ADR sidecar is excluded from cbm_is_project_index_db, + * but a full index wipe must still remove it (and any orphan whose index + * is already gone) — otherwise the next re-index of that name inherits a + * stale ADR. It appears as its own dirent, so remove it directly. */ + bool is_adr = cli_is_adr_sidecar(ent->name, strlen(ent->name)); + if (!is_index && !is_adr) { + continue; + } + char path[CLI_BUF_1K]; + snprintf(path, sizeof(path), "%s/%s", cache_dir, ent->name); + if (is_index) { /* Also remove .db.tmp if present */ char tmp_path[CLI_FIELD_1040]; snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); @@ -7517,6 +7553,11 @@ int cbm_remove_indexes(const char *home_dir) { * its .db. Sidecars are not indexes, so count is unchanged. */ cbm_remove_db_sidecars(path); cbm_remove_db_sidecars(tmp_path); + } else { + /* ADR sidecar: remove it and its own -wal/-shm/-journal; not an + * index, so count is unchanged. */ + cbm_unlink(path); + cbm_remove_db_sidecars(path); } } cbm_closedir(d); @@ -10754,7 +10795,8 @@ static int count_db_indexes(const char *home) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { - if (cbm_is_project_index_db(ent->name)) { + if (cbm_is_project_index_db(ent->name) && + !cli_is_adr_sidecar(ent->name, strlen(ent->name))) { count++; } } diff --git a/src/foundation/str_util.c b/src/foundation/str_util.c index 00c6b4e68..76b30deb2 100644 --- a/src/foundation/str_util.c +++ b/src/foundation/str_util.c @@ -317,7 +317,9 @@ bool cbm_is_internal_cache_db(const char *filename) { bool cbm_is_project_index_db(const char *filename) { static const char db_ext[] = ".db"; + static const char adr_ext[] = ".adr.db"; const size_t ext_len = sizeof(db_ext) - 1; + const size_t adr_len = sizeof(adr_ext) - 1; if (!filename) { return false; } @@ -325,6 +327,12 @@ bool cbm_is_project_index_db(const char *filename) { if (len <= ext_len || strcmp(filename + len - ext_len, db_ext) != 0) { return false; } + /* A per-project ADR sidecar (".adr.db") ends in ".db" but is not itself + * an index — exclude it from every index scan/count (MCP loops + the CLI's + * cbm_list_indexes / count_db_indexes all gate on this helper). */ + if (len >= adr_len && strcmp(filename + len - adr_len, adr_ext) == 0) { + return false; + } return !cbm_is_internal_cache_db(filename); } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index e78c80830..de03ec06c 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -769,7 +769,7 @@ static const tool_def_t TOOLS[] = { "\"offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"metadata_only\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Compatibility: omit counts, size, and branch.\"}}}"}, - {"delete_project", "Delete a project from the index", + {"delete_project", "Delete a project from the index, including its ADR store", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" "\"project\"]}"}, @@ -7885,7 +7885,9 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { return result; } -/* delete_project: just erase the .db file (and WAL/SHM). */ +/* delete_project: erase the .db file (and WAL/SHM) plus the ADR sidecar + * ".adr.db" (and its WAL/SHM/journal), so deleting a project removes its + * ADR store with it. */ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { char *name = get_project_arg(args); if (!name) { @@ -7924,18 +7926,37 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { const char *error_detail = NULL; bool is_error = false; + /* ADR sidecar lives beside the graph DB as ".adr.db" — remove it (and + * its own WAL/SHM/journal) whenever the project is deleted. Sized as the + * graph path plus the ".adr.db" suffix. */ + char adr_path[CBM_SZ_1K + sizeof(".adr.db")]; + int adr_n = snprintf(adr_path, sizeof(adr_path), "%s.adr.db", path); + bool have_adr_path = adr_n > 0 && (size_t)adr_n < sizeof(adr_path); + if (exists) { int rc = cbm_unlink(path); (void)cbm_unlink(wal); (void)cbm_unlink(shm); if (rc == 0) { status = "deleted"; + /* Only drop the ADR store once the graph DB is actually gone, so a + * failed graph delete leaves the project (and its ADR) intact. */ + if (have_adr_path) { + (void)cbm_unlink(adr_path); + (void)cbm_remove_db_sidecars(adr_path); + } } else { status = "delete_failed"; error_detail = strerror(errno); is_error = true; } } else { + /* No graph DB, but a sidecar could still linger (e.g. ADR written then + * the index deleted out of band) — best-effort clean it up. */ + if (have_adr_path) { + (void)cbm_unlink(adr_path); + (void)cbm_remove_db_sidecars(adr_path); + } is_error = true; } @@ -20814,9 +20835,10 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { } } - /* ADRs are stored in the SQLite store (project_summaries), the SAME - * backend the UI /api/adr endpoints use — so writes via the MCP tool and - * the UI are visible to each other (#256). */ + /* ADRs are stored in the per-project ADR sidecar DB (".adr.db"), the + * SAME backend the UI /api/adr endpoints use (via cbm_store_adr_*) — so + * writes via the MCP tool and the UI are visible to each other (#256), and + * a reindex of the graph DB never disturbs them. */ store_recovery_status_t recovery_status = STORE_RECOVERY_NONE; cbm_store_t *resolved = resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status, true); diff --git a/src/pipeline/artifact.c b/src/pipeline/artifact.c index 0d6ef3010..f3b6098ef 100644 --- a/src/pipeline/artifact.c +++ b/src/pipeline/artifact.c @@ -884,13 +884,54 @@ static void artifact_snapshot_tmp_close(artifact_snapshot_tmp_t *tmp) { tmp->dir[0] = '\0'; } +/* Make the snapshot's project_summaries carry exactly the current ADR — and + * nothing stale. ADRs now live in the ".adr.db" sidecar, but a legacy + * pre-sidecar row can still linger in the graph DB and gets copied into the + * snapshot by VACUUM INTO; incremental/closure-repair clones keep carrying it. + * So ALWAYS clear project_summaries first, then insert only when an ADR is + * present (`adr` is NULL when the sidecar has none). Otherwise a no-ADR export + * would ship the stale row and a teammate's import would restore a decision + * record that was deleted. Best-effort: on any failure the artifact simply + * omits the ADR, as before the sidecar existed. */ +static void snapshot_inject_adr(const char *snapshot_path, const cbm_adr_t *adr) { + sqlite3 *db = NULL; + if (sqlite3_open_v2(snapshot_path, &db, SQLITE_OPEN_READWRITE, NULL) != SQLITE_OK) { + sqlite3_close(db); + return; + } + sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS project_summaries (" + " project TEXT PRIMARY KEY, summary TEXT NOT NULL, source_hash TEXT NOT NULL," + " created_at TEXT NOT NULL, updated_at TEXT NOT NULL);", + NULL, NULL, NULL); + /* Drop any stale row (a deleted ADR must not resurrect through the export). */ + sqlite3_exec(db, "DELETE FROM project_summaries;", NULL, NULL, NULL); + if (adr && adr->project && adr->content) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "INSERT INTO project_summaries " + "(project, summary, source_hash, created_at, updated_at) " + "VALUES (?1, ?2, '', ?3, ?4);", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, adr->project, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, adr->content, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, adr->created_at ? adr->created_at : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, adr->updated_at ? adr->updated_at : "", -1, SQLITE_TRANSIENT); + sqlite3_step(st); + sqlite3_finalize(st); + } + } + sqlite3_close(db); +} + /* Prepare a stripped DB copy for best-quality export. - * VACUUM INTO → (optionally) drop indexes → VACUUM. Returns malloc'd buffer - * or NULL. VACUUM INTO runs on BOTH quality levels: it is the consistent - * snapshot — the store runs in WAL mode, so raw main-file bytes miss - * committed transactions still in the -wal and can be mid-checkpoint torn - * (#895). Only the index-stripping is BEST-only. */ -static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes) { + * VACUUM INTO → (optionally) drop indexes → VACUUM → (optionally) inject the + * ADR. Returns malloc'd buffer or NULL. VACUUM INTO runs on BOTH quality + * levels: it is the consistent snapshot — the store runs in WAL mode, so raw + * main-file bytes miss committed transactions still in the -wal and can be + * mid-checkpoint torn (#895). Only the index-stripping is BEST-only. */ +static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes, + const cbm_adr_t *adr) { artifact_snapshot_tmp_t tmp; if (!artifact_snapshot_tmp_open(&tmp)) { artifact_export_fail("prepare_snapshot_dir", cbm_tmpdir(), "private_tmpdir_failed", errno); @@ -935,6 +976,11 @@ static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool str } } + /* Carry the ADR (from the sidecar) into the snapshot so the exported + * artifact ships the team's decision record. After the strip/VACUUM, so the + * row is not dropped. */ + snapshot_inject_adr(tmp_path, adr); + /* Reopened by path rather than held on a descriptor across VACUUM INTO, * because sqlite owns the create. The private directory is what makes that * safe: an attacker who cannot enter it cannot swap the file underneath. */ @@ -980,15 +1026,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * char *db_data = NULL; int compression_level = ART_ZSTD_FAST; + /* Fetch the ADR from the sidecar (read-only; does not create it) so the + * snapshot can carry it — the team-shared artifact used to include the ADR + * via project_summaries, and the sidecar must not silently break that. */ + cbm_adr_t adr = {0}; + bool have_adr = false; + cbm_store_t *adr_src = cbm_store_open_path_query(db_path); + if (adr_src) { + have_adr = cbm_store_adr_get(adr_src, project_name, &adr) == CBM_STORE_OK; + cbm_store_close(adr_src); + } + const cbm_adr_t *adr_arg = have_adr ? &adr : NULL; + if (quality == CBM_ARTIFACT_BEST) { compression_level = ART_ZSTD_BEST; - db_data = prepare_snapshot_db(db_path, &db_size, true); + db_data = prepare_snapshot_db(db_path, &db_size, true, adr_arg); } else { /* FAST keeps zstd-3 and its indexes, but still snapshots via * VACUUM INTO: the raw main-file bytes of a live WAL store are a * torn copy (#895). */ - db_data = prepare_snapshot_db(db_path, &db_size, false); + db_data = prepare_snapshot_db(db_path, &db_size, false, adr_arg); } + cbm_store_adr_free(&adr); if (!db_data || db_size == 0) { free(db_data); @@ -1071,6 +1130,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * /* ── Import ──────────────────────────────────────────────────────── */ +/* Restore the imported ADR (carried in the graph DB's project_summaries) into + * the per-project sidecar so it arrives with the shared artifact — even for a + * teammate who already indexed the project (a marked sidecar with no ADR), + * which the marker-gated migrate-once would skip. INSERT OR IGNORE means a + * local ADR is never overwritten. Best-effort: the graph DB is already + * installed when this runs, so a failed restore never fails the import, but it + * is logged with the store's reason so a team ADR that did not arrive is + * diagnosable instead of silently missing. */ +static void artifact_import_restore_adr(const char *cache_db_path) { + cbm_store_t *restore = cbm_store_open_path_existing(cache_db_path); + if (!restore) { + cbm_log_warn("artifact.import", "err", "adr_restore", "detail", "reopen_imported_db", + "path", cache_db_path); + return; + } + if (cbm_store_adr_restore_from_graph_if_absent(restore) != CBM_STORE_OK) { + cbm_log_warn("artifact.import", "err", "adr_restore", "detail", cbm_store_error(restore), + "path", cache_db_path); + } + cbm_store_close(restore); +} + int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { if (!repo_path || !cache_db_path) { return CBM_NOT_FOUND; @@ -1198,6 +1279,8 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { cbm_unlink(wal); cbm_unlink(shm); + artifact_import_restore_adr(cache_db_path); + cbm_log_info("artifact.import", "db", cache_db_path, "size_mb", itoa_buf((int)((size_t)dlen / ART_BYTES_PER_MB))); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 009a01da3..a673e7539 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -1361,6 +1361,14 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { return 0; } + /* A bare Go `init(...)` calls a local closure or parameter: never a + * package init (unreferable in Go) and never a same-named method found + * by name alone — see cbm_go_suppress_bare_init_bind. MUST match + * pass_parallel.c exactly. */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name, + res.strategy, target_node->label)) { + return 0; + } evidence->candidate_qn = target_node->qualified_name; evidence->calls_emitted |= emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 1f57d1c25..830cacfe1 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -720,6 +720,16 @@ static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *s char method[CBM_SZ_32] = {0}; json_str_prop(props, "url_path", url_path, sizeof(url_path)); json_str_prop(props, "method", method, sizeof(method)); + if (!method[0]) { + char callee[CBM_SZ_256] = {0}; + json_str_prop(props, "callee", callee, sizeof(callee)); + if (callee[0]) { + const char *m = cbm_service_pattern_http_method(callee); + if (m) { + snprintf(method, sizeof(method), "%s", m); + } + } + } if (!url_path[0]) { continue; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 50980cbdc..e02d68c67 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2621,19 +2621,37 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, if (!cbm_service_pattern_is_http_route_literal(norm, call->callee_name)) { continue; } + const char *method = cbm_service_pattern_http_method(call->callee_name); + const char *prefix = method ? method : "ANY"; char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; - snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, cbm_route_canon_path(norm, cpath, sizeof(cpath))); - int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, - "{\"source\":\"arg_url\"}"); + char route_props[CBM_SZ_256]; + if (method) { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\",\"method\":\"%s\"}", + method); + } else { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\"}"); + } + int64_t route_id = + cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, route_props); char esc_c[CBM_SZ_256]; char esc_n[CBM_SZ_256]; cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name); cbm_json_escape(esc_n, sizeof(esc_n), norm); - char eprops[CBM_SZ_512]; - snprintf(eprops, sizeof(eprops), - "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + /* Two escaped 255-byte strings plus the fixed keys and the verb: 1K + * never truncates the JSON (512 could, once the method was added). */ + char eprops[CBM_SZ_1K]; + if (method) { + snprintf( + eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\",\"method\":\"%s\"}", + esc_c, esc_n, method); + } else { + snprintf(eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + } cbm_gbuf_insert_edge(gbuf, source->id, route_id, "HTTP_CALLS", eprops); break; } @@ -2985,6 +3003,29 @@ static const cbm_gbuf_node_t *find_source_node(const cbm_gbuf_t *gbuf, const cha /* Field type hint resolution for obj.Method() with multiple candidates. * Strips C# field prefixes (_ / m_), capitalizes to get type name, and * checks if TypeName.Method or ITypeName.Method exists among candidates. */ +/* #1927: the hint may only bind a candidate whose OWNING dot-segment — the + * segment immediately before the method name — EQUALS the hinted type. The + * previous raw strstr() accepted the hinted name anywhere in the QN, so a + * single-letter receiver (`f.Close()` → hint "F") matched nearly every + * candidate ("F" ⊂ "…FileStore.Close", the method name included) and rebound + * a call whose resolution had already failed to an arbitrary project symbol + * at PP_FIELD_HINT_CONF. Segment equality keeps the intended + * variable-named-after-its-type case (`repo.Find()` → `Repo.Find`, + * lrp_go_s8_field_type_hint) and picks the candidate whose owner actually + * carries the name instead of whichever QN contains the letters. */ +static bool fth_owner_segment_is(const char *candidate_qn, const char *type_name) { + const char *last_dot = strrchr(candidate_qn, '.'); + if (!last_dot || last_dot == candidate_qn) { + return false; + } + const char *seg_start = last_dot; + while (seg_start > candidate_qn && seg_start[-1] != '.') { + seg_start--; + } + size_t seg_len = (size_t)(last_dot - seg_start); + return seg_len > 0 && strncmp(seg_start, type_name, seg_len) == 0 && type_name[seg_len] == '\0'; +} + static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const char *callee_name, int64_t source_id) { if (!res->qualified_name || res->candidate_count <= SKIP_ONE) { @@ -3024,7 +3065,8 @@ static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const int cand_count = 0; cbm_registry_find_by_name(rc->registry, method, &cands, &cand_count); for (int ci = 0; ci < cand_count; ci++) { - if (strstr(cands[ci], type_name) || strstr(cands[ci], iface_name)) { + if (fth_owner_segment_is(cands[ci], type_name) || + fth_owner_segment_is(cands[ci], iface_name)) { const cbm_gbuf_node_t *better = cbm_gbuf_find_by_qn(rc->main_gbuf, cands[ci]); if (better && better->id != source_id) { res->qualified_name = cands[ci]; @@ -3659,6 +3701,13 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * Variable or a Field. */ continue; } + if (target_node && source_node->id != target_node->id && + cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name, + res.strategy, target_node->label)) { + /* Same guard as pass_calls.c — a bare Go `init` is a local name, + * never a package init or a method found by name alone. */ + continue; + } if (!target_node || source_node->id == target_node->id) { /* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`) * resolve to an unindexed QN (target_node == NULL), but their edge @@ -3835,6 +3884,12 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind). + * Mirrors the sequential twin (pass_usages.c). */ + if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access, + usage->ref_name, res.strategy, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -3963,6 +4018,12 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind). + * Mirrors the sequential twin (pass_usages.c). */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name, + res.strategy, tgt->label)) { + continue; + } const char *etype = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}"); } diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index b51573deb..e29290c74 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -288,6 +288,12 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local, never a package init or an init + * method (cbm_go_suppress_bare_init_bind). Mirrors pass_parallel.c. */ + if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access, + usage->ref_name, res.strategy, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -420,6 +426,13 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) { continue; } + /* `init := func(...)`: a bare Go `init` names a local, never a package + * init or an init method (cbm_go_suppress_bare_init_bind). Mirrors + * pass_parallel.c. */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name, + res.strategy, tgt->label)) { + continue; + } const char *edge_type = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}"); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 657e25cd4..64bc57dba 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -97,7 +97,6 @@ int cbm_pipeline_worker_count_test_last(void) { static atomic_bool g_persist_test_fail_after_stage_dump = false; static atomic_bool g_persist_test_cancel_after_predump = false; static atomic_bool g_persist_test_cancel_after_destination_prepare = false; -static atomic_bool g_persist_test_fail_adr_capture = false; static cbm_pipeline_test_hook_fn g_persist_test_before_final_manifest = NULL; static void *g_persist_test_before_final_manifest_userdata = NULL; static cbm_pipeline_test_hook_fn g_persist_test_after_stage_created = NULL; @@ -115,10 +114,6 @@ void cbm_pipeline_incremental_test_cancel_after_destination_prepare_once(void) { atomic_store(&g_persist_test_cancel_after_destination_prepare, true); } -void cbm_pipeline_incremental_test_fail_adr_capture_once(void) { - atomic_store(&g_persist_test_fail_adr_capture, true); -} - void cbm_pipeline_incremental_test_before_final_manifest_once(cbm_pipeline_test_hook_fn hook, void *userdata) { g_persist_test_before_final_manifest = hook; @@ -174,7 +169,6 @@ void cbm_pipeline_persist_test_reset_faults(void) { atomic_store(&g_persist_test_fail_after_stage_dump, false); atomic_store(&g_persist_test_cancel_after_predump, false); atomic_store(&g_persist_test_cancel_after_destination_prepare, false); - atomic_store(&g_persist_test_fail_adr_capture, false); g_persist_test_before_final_manifest = NULL; g_persist_test_before_final_manifest_userdata = NULL; g_persist_test_after_stage_created = NULL; @@ -290,10 +284,6 @@ struct cbm_pipeline { bool live_noop; const char *live_db_path; /* borrowed from cbm_pipeline_run */ - /* ADR (project_summaries) captured before a full-reindex DB delete, so it - * can be restored after the rebuild. NULL when no ADR existed. Issue #516. */ - char *saved_adr; - /* Per-file LSP surfaces serialized at the collect_all_defs seam (the only * moment the result cache is alive), persisted by dump_and_persist_hashes * so the closure-repair incremental route can early-cutoff on surface @@ -1491,9 +1481,6 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { p->file_errors_count = 0; p->file_errors_cap = 0; free(p->branch_qn); - free(p->saved_adr); /* freed here too: error paths can exit before the - * restore in dump_and_persist_hashes runs. Issue #516. */ - p->saved_adr = NULL; cbm_store_free_lsp_surfaces(p->surface_rows, p->surface_row_count); p->surface_rows = NULL; p->surface_row_count = 0; @@ -3183,38 +3170,39 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, return check_cancel(p) ? CBM_NOT_FOUND : 0; } -static int capture_existing_adr(cbm_pipeline_t *p, const char *db_path) { -#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API - if (atomic_exchange(&g_persist_test_fail_adr_capture, false)) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; +static size_t stage_root_length(const char *path); + +/* Move a legacy ADR row (pre-sidecar, still in the graph DB's project_summaries) + * into the ".adr.db" sidecar BEFORE a reindex unlinks the old generation — + * otherwise the row is lost on an upgrade whose first action is a full/format + * reindex. Read-only on the graph DB (migration writes only the sidecar, on its + * own connection). One-time via the sidecar's marker; a no-op when there is no + * legacy row. Returns true on success (safe to delete), false to preserve. */ +static bool adr_migrate_before_delete(const char *db_path) { + /* `db_path` in routing is a staging copy; the live generation (and the + * sidecar the reader looks for) is keyed to the FINAL path — the staging + * suffix stripped. Migrate against that, so the ADR lands in + * ".adr.db", not a discarded stage's sidecar. */ + size_t root_len = stage_root_length(db_path); + char final_path[4096]; + if (root_len >= sizeof(final_path)) { + return false; } -#endif - cbm_store_t *adr_store = cbm_store_open_path_query(db_path); - if (!adr_store) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; + memcpy(final_path, db_path, root_len); + final_path[root_len] = '\0'; + /* The live final DB must exist to carry a legacy ADR forward; if it does + * not (a first index), there is nothing to migrate — succeed. But + * cbm_store_open_path_query returns NULL on an open *failure* too, not only + * a missing file, so only proceed when the file genuinely does not exist; + * a real open failure must preserve-and-abort rather than publish over a + * legacy ADR it never moved. */ + cbm_store_t *mig = cbm_store_open_path_query(final_path); + if (!mig) { + return !cbm_file_exists(final_path); } - cbm_adr_t existing = {0}; - int adr_rc = cbm_store_adr_get(adr_store, p->project_name, &existing); - if (adr_rc == CBM_STORE_NOT_FOUND) { - cbm_store_close(adr_store); - free(p->saved_adr); - p->saved_adr = NULL; - return 0; - } - if (adr_rc != CBM_STORE_OK || !existing.content) { - cbm_store_adr_free(&existing); - cbm_store_close(adr_store); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - char *saved = strdup(existing.content); - cbm_store_adr_free(&existing); - cbm_store_close(adr_store); - if (!saved) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - free(p->saved_adr); - p->saved_adr = saved; - return 0; + int rc = cbm_store_adr_migrate_once(mig); + cbm_store_close(mig); + return rc == CBM_STORE_OK; } static int remove_stage_files(const char *stage_path); @@ -3320,11 +3308,19 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file cbm_log_info("pipeline.route", "path", "format_change_reindex", "stored_format", itoa_buf(fmt)); p->format_migration = true; - int adr_rc = capture_existing_adr(p, db_path); + /* Carry a legacy ADR into the sidecar before deleting the old graph DB; + * preserve and abort if that fails, so the decision record is never lost + * to a rebuild. The sidecar itself is untouched by cbm_remove_db_sidecars + * (it strips only -wal/-shm/-journal). */ + if (!adr_migrate_before_delete(db_path)) { + cbm_log_warn("pipeline.route", "reason", "adr_migrate_failed"); + free(db_path); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } (void)cbm_unlink(db_path); (void)cbm_remove_db_sidecars(db_path); free(db_path); - return adr_rc != 0 ? adr_rc : CBM_PIPELINE_FORCE_FULL_REINDEX; + return CBM_PIPELINE_FORCE_FULL_REINDEX; } cbm_log_info("pipeline.route", "path", "incremental_manifest"); @@ -3337,9 +3333,13 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file * no-op and successful-incremental routes -- the pipeline reports success * while every later reader finds no store. */ if (rc == CBM_PIPELINE_FORCE_FULL_REINDEX) { - int adr_rc = capture_existing_adr(p, db_path); - if (adr_rc != 0) { - rc = adr_rc; + /* Carry a legacy ADR into the sidecar before deleting the old graph DB; + * preserve and abort if that fails. The sidecar survives the delete + * (cbm_remove_db_sidecars strips only -wal/-shm/-journal). */ + if (!adr_migrate_before_delete(db_path)) { + cbm_log_warn("pipeline.route", "reason", "adr_migrate_failed"); + free(db_path); + return CBM_PIPELINE_ABORT_PRESERVE_DB; } (void)cbm_unlink(db_path); (void)cbm_remove_db_sidecars(db_path); @@ -3861,10 +3861,8 @@ static int publish_staged_impl(char *stage_path, const cbm_pipeline_generation_t cbm_store_upsert_lsp_surface_batch(store, generation->surface_rows, generation->surface_row_count) == CBM_STORE_OK; } - if (ok && generation->adr_content) { - ok = cbm_store_adr_store(store, generation->project, generation->adr_content) == - CBM_STORE_OK; - } + /* ADRs are NOT written here: they live in the ".adr.db" sidecar, which + * this rebuild never touches, so there is nothing to re-apply. */ /* Doc -> code candidates belong to the generation too: replaced inside * the staging store (the table is created here), so a reader never sees * candidates of another graph. */ @@ -4152,7 +4150,6 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas .cancelled = p->cancelled, .manifest = manifest, .manifest_count = manifest_count, - .adr_content = p->saved_adr, .coverage = cov, .coverage_count = cov_count, .coverage_meta = @@ -4206,8 +4203,6 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas cbm_log_warn("index.ignored_capped", "stored", itoa_buf(p->ignored_count), "total", itoa_buf(p->ignored_total)); } - free(p->saved_adr); - p->saved_adr = NULL; free(db_path); return 0; diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index a2e09acdb..a759b6821 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -476,6 +476,16 @@ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *st bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, double confidence); +/* A bare Go `init` (a call or reference that is not the member half of a + * selector) is always a local name: Go forbids referring to a package init + * function, and a method or field needs a selector. True when such a name + * resolved onto a Function (every Go Function named init is a package init; + * any registry strategy) or onto a Method or Field found by a weak short-name + * strategy. lsp_* answers are kept. Go only. Pure; unit-tested in + * test_registry.c; wired in pass_calls.c, pass_usages.c and pass_parallel.c. */ +bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name, + const char *strategy, const char *target_label); + /* Import-binding counterpart (#2127). True when the callee's root identifier * is bound by an import of this file whose module chain contradicts the * resolved target (`from unittest.mock import patch; patch()` must not bind a diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 5142e3a53..ac2c253af 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -2239,8 +2239,7 @@ typedef struct { static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, atomic_int *cancelled, const cbm_file_hash_t *manifest, - int manifest_count, const char *adr_content, - const cbm_coverage_row_t *cov, int cov_count, + int manifest_count, const cbm_coverage_row_t *cov, int cov_count, const cbm_coverage_meta_t *meta_template, const cbm_lsp_surface_row_t *surface_rows, int surface_row_count, legacy_doc_rows_t doc, const cbm_doc_candidate_t *doc_candidates, @@ -2254,7 +2253,6 @@ static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *p .cancelled = cancelled, .manifest = manifest, .manifest_count = manifest_count, - .adr_content = adr_content, .coverage = cov, .coverage_count = cov_count, .coverage_meta = meta_template ? *meta_template : (cbm_coverage_meta_t){0}, @@ -3845,7 +3843,6 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .cancelled = cbm_pipeline_cancelled_ptr(p), .manifest = manifest, .manifest_count = manifest_count, - .adr_content = NULL, /* the clone already carries the ADR rows */ .coverage = cov, .coverage_count = cov_n, .coverage_meta = @@ -4238,43 +4235,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil return CBM_NOT_FOUND; } - char *saved_adr = NULL; - cbm_adr_t existing_adr = {0}; - int adr_rc = cbm_store_adr_get(store, project, &existing_adr); - if (adr_rc == CBM_STORE_OK) { - bool had_adr_content = existing_adr.content != NULL; - if (had_adr_content) { - saved_adr = strdup(existing_adr.content); - } - cbm_store_adr_free(&existing_adr); - if (had_adr_content && !saved_adr) { - cbm_gbuf_free(existing); - free(changed_files); - for (int i = 0; i < deleted_count; i++) { - free(deleted[i]); - } - free(deleted); - free_mode_skipped(mode_skipped, mode_skipped_count); - cbm_store_free_coverage(old_cov, old_cov_count); - cbm_store_close(store); - closure_plan_free(&closure_plan); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - } else if (adr_rc != CBM_STORE_NOT_FOUND) { - cbm_store_adr_free(&existing_adr); - cbm_gbuf_free(existing); - free(changed_files); - for (int i = 0; i < deleted_count; i++) { - free(deleted[i]); - } - free(deleted); - free_mode_skipped(mode_skipped, mode_skipped_count); - cbm_store_free_coverage(old_cov, old_cov_count); - cbm_store_close(store); - closure_plan_free(&closure_plan); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - + /* ADRs live in the ".adr.db" sidecar, which the delta clone/patch/rename + * never touches, so there is nothing to capture or re-apply here. */ legacy_doc_t legacy_doc; legacy_doc_load(&legacy_doc, store, project, changed_files, ci, deleted, deleted_count); cbm_store_close(store); @@ -4434,7 +4396,6 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil incr_free_edge_capture(&edge_cap); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); - free(saved_adr); cbm_gbuf_free(existing); cbm_arena_destroy(&decl_arena); legacy_doc_free(&legacy_doc); @@ -4556,7 +4517,6 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); - free(saved_adr); cbm_gbuf_free(existing); cbm_arena_destroy(&decl_arena); cbm_doclinks_free_rows(doc_rows, doc_row_count); @@ -4594,12 +4554,12 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil * rehydrated project, so this run's rows are complete. */ int doc_cand_n = 0; const cbm_doc_candidate_t *doc_cands = cbm_pipeline_doc_candidates(p, &doc_cand_n); + /* ADRs are not captured/re-applied here (sidecar store), so no adr argument. */ int persist_rc = dump_and_persist(existing, db_path, project, cbm_pipeline_cancelled_ptr(p), - manifest, manifest_count, saved_adr, cov, cov_n, - &coverage_meta, NULL, 0, doc_out, doc_cands, doc_cand_n); + manifest, manifest_count, cov, cov_n, &coverage_meta, NULL, 0, + doc_out, doc_cands, doc_cand_n); cbm_doclinks_free_rows(doc_rows, doc_row_count); cbm_pipeline_free_semantic_manifest(manifest, manifest_count); - free(saved_adr); free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 0d887669a..86ac44cd7 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1694,7 +1694,6 @@ typedef struct { atomic_int *cancelled; const cbm_file_hash_t *manifest; int manifest_count; - const char *adr_content; const cbm_coverage_row_t *coverage; int coverage_count; cbm_coverage_meta_t coverage_meta; @@ -1960,7 +1959,6 @@ void cbm_pipeline_incremental_test_force_legacy_partial_once(void); void cbm_pipeline_incremental_test_fail_after_stage_dump_once(void); void cbm_pipeline_incremental_test_cancel_after_predump_once(void); void cbm_pipeline_incremental_test_cancel_after_destination_prepare_once(void); -void cbm_pipeline_incremental_test_fail_adr_capture_once(void); typedef void (*cbm_pipeline_test_hook_fn)(void *userdata); void cbm_pipeline_incremental_test_before_final_manifest_once(cbm_pipeline_test_hook_fn hook, void *userdata); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index ee1eeb943..997d00194 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1015,6 +1015,46 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s return confidence < CONF_UNIQUE_NAME; } +bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name, + const char *strategy, const char *target_label) { + /* The Go spec gives `init` no binding in the package block: the + * identifier declares init functions and nothing else, and "init + * functions cannot be referred to from anywhere in a program". A method + * or a field named init is reached only through a selector (`x.init()`, + * `e.init`), which arrives here as is_member and is left alone. So a + * BARE `init` is always a local name: a closure (`init := + * func(ctx) {...}; init(ctx)`, 22 kubernetes e2e storage suites), a + * parameter (`func sum(init func() ref.Val)`), or the `package init` + * clause of a directory named init. None of them is a project node. + * + * Every Go Function named init is a package init (no other package-level + * `init` can be declared), so that target is refused for every registry + * strategy. Before #1915 the flat QN made same_module bind the bare name + * to its own package's init; after it, the per-file QN misses there and + * the name fallback binds unrelated init methods instead. A Method or + * Field namesake is refused when only the name found it (the shared weak + * drop-list), which is how every such edge was minted. lsp_* answers are + * untouched: the sequential resolver emits them before its registry + * guards run, so refusing one here would split the two resolvers. + * + * Go-gated: elsewhere `init` is an ordinary name. A bare `init()` in a + * Java, C#, C++, Kotlin or Ruby method calls the class's own method, and + * a C, Python or JS `init` function is called by its bare name. */ + if (!is_go || is_member || !name || strcmp(name, "init") != 0 || !target_label) { + return false; + } + if (strategy && strncmp(strategy, "lsp_", strlen("lsp_")) == 0) { + return false; + } + if (strcmp(target_label, "Function") == 0) { + return true; + } + if (strcmp(target_label, "Method") == 0 || strcmp(target_label, "Field") == 0) { + return weak_short_name_strategy(strategy); + } + return false; +} + /* Import-binding counterpart of the parameter guard above (#2127). An import * binds the identifier for the whole module; when that import is external it * never materializes, and the chain falls through to project-wide short-name diff --git a/src/store/store.c b/src/store/store.c index c7abb3f0b..ed06236a9 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -183,6 +183,15 @@ struct cbm_store { sqlite3_stmt *stmt_get_file_hashes; sqlite3_stmt *stmt_delete_file_hash; sqlite3_stmt *stmt_delete_file_hashes; + + /* ADRs live in a per-project sidecar DB (".adr.db"), NOT in the + * graph DB, so a reindex that rebuilds/replaces the graph DB never touches + * them (removing the capture/re-apply race entirely). The read-write handle + * is lazily opened on the first ADR write/migration and cached for the + * store's lifetime; NULL for a :memory: store, where ADRs stay in the graph + * DB (never file-swapped, no race). Reads open a short-lived read-only + * handle so a query-only cache is never mutated. */ + sqlite3 *adr_db; }; /* ── Helpers ────────────────────────────────────────────────────── */ @@ -195,6 +204,13 @@ static void store_set_error_sqlite(cbm_store_t *s, const char *prefix) { snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(s->db)); } +/* Like store_set_error_sqlite but reads the message from an explicit + * connection — used for the ADR sidecar DB, whose errors do not live on + * s->db. */ +static void store_set_error_sqlite_on(cbm_store_t *s, sqlite3 *db, const char *prefix) { + snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(db)); +} + static int exec_sql(cbm_store_t *s, const char *sql) { if (!s || !s->db) { return CBM_STORE_ERR; @@ -1530,6 +1546,11 @@ void cbm_store_close(cbm_store_t *s) { * Prevents ASan false-positive leaks from sqlite3 internal state. */ sqlite3_close_v2(s->db); cbm_graph_mem_release(s->graph_mem); + /* ADR sidecar: checkpoint + close its own connection (file stores only). */ + if (s->adr_db) { + (void)sqlite3_wal_checkpoint_v2(s->adr_db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL); + sqlite3_close_v2(s->adr_db); + } safe_str_free(&s->db_path); free(s); } @@ -11923,7 +11944,260 @@ void cbm_adr_sections_free(cbm_adr_sections_t *s) { memset(s, 0, sizeof(*s)); } +/* ── ADR sidecar store ────────────────────────────────────────────── + * ADRs are kept in a per-project sidecar SQLite DB (".adr.db"), so a + * reindex that deletes/replaces the graph DB never disturbs them (no capture, + * re-read or re-apply, no lock). adr_conn() returns the connection ADR ops run + * on: the sidecar for a file-backed store (opened, schema-created and migrated + * once, then cached), or the graph DB for a :memory: store (never file-swapped, + * so there is no race to avoid there). */ + +/* The sidecar's on-disk path (".adr.db"), routed through + * cbm_path_for_file_api so a long path gets the Windows \\?\ prefix (like + * store_open_internal). Returns false for a :memory: store or an over-long + * path. `raw` receives the plain path for existence checks/unlinks; `api` + * receives the file-API form used to open. */ +static bool adr_sidecar_path(const cbm_store_t *s, char *raw, size_t raw_sz, char *api, + size_t api_sz) { + if (!s->db_path) { + return false; + } + int n = snprintf(raw, raw_sz, "%s.adr.db", s->db_path); + if (n <= 0 || (size_t)n >= raw_sz) { + return false; + } + return cbm_path_for_file_api(raw, api, api_sz); +} + +/* Match the graph store's locking so cross-process ADR access waits rather than + * failing: WAL + a 10 s busy timeout (SQLite's own lock wait, not a hand-rolled + * poll). Best-effort: a read-only sidecar rejects journal_mode, which is fine. */ +static void adr_configure(sqlite3 *db) { + (void)sqlite3_exec(db, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL); + (void)sqlite3_exec(db, "PRAGMA journal_mode = WAL;", NULL, NULL, NULL); +} + +static int adr_ensure_schema(sqlite3 *db) { + /* project_summaries has the same shape as the graph DB's, so migration is a + * plain row copy and the cbm_adr_t mapping is unchanged. adr_meta carries a + * one-time "migrated" marker so the legacy copy runs exactly once and can + * never resurrect a row deleted after migration. */ + int rc = sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS project_summaries (" + " project TEXT PRIMARY KEY," + " summary TEXT NOT NULL," + " source_hash TEXT NOT NULL," + " created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL" + ");" + "CREATE TABLE IF NOT EXISTS adr_meta (" + " key TEXT PRIMARY KEY," + " value TEXT NOT NULL" + ");", + NULL, NULL, NULL); + return (rc == SQLITE_OK) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +/* True once the one-time legacy migration has been recorded in the sidecar. */ +static bool adr_migrated(sqlite3 *adb) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(adb, "SELECT 1 FROM adr_meta WHERE key='migrated' LIMIT 1;", + CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { + return false; + } + bool done = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + return done; +} + +/* Copy every ADR row from the graph DB's project_summaries into the sidecar with + * INSERT OR IGNORE (so a row already present locally is never overwritten). Must + * run inside a transaction on the sidecar connection. Returns OK/ERR; does not + * touch the `migrated` marker. */ +static int adr_copy_rows(cbm_store_t *s, sqlite3 *adb) { + sqlite3_stmt *probe = NULL; + if (sqlite3_prepare_v2( + s->db, + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", + CBM_NOT_FOUND, &probe, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + bool has_legacy = sqlite3_step(probe) == SQLITE_ROW; + sqlite3_finalize(probe); + if (!has_legacy) { + return CBM_STORE_OK; /* no source table -> nothing to copy */ + } + + sqlite3_stmt *sel = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT project, summary, source_hash, created_at, updated_at " + "FROM project_summaries;", + CBM_NOT_FOUND, &sel, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_stmt *ins = NULL; + if (sqlite3_prepare_v2(adb, + "INSERT OR IGNORE INTO project_summaries " + "(project, summary, source_hash, created_at, updated_at) " + "VALUES (?1, ?2, ?3, ?4, ?5);", + CBM_NOT_FOUND, &ins, NULL) != SQLITE_OK) { + sqlite3_finalize(sel); + return CBM_STORE_ERR; + } + int rc = SQLITE_DONE; + while ((rc = sqlite3_step(sel)) == SQLITE_ROW) { + for (int c = 0; c < ST_COL_5; c++) { + bind_text(ins, c + 1, (const char *)sqlite3_column_text(sel, c)); + } + int irc = sqlite3_step(ins); + sqlite3_reset(ins); + sqlite3_clear_bindings(ins); + if (irc != SQLITE_DONE) { + sqlite3_finalize(sel); + sqlite3_finalize(ins); + return CBM_STORE_ERR; + } + } + sqlite3_finalize(sel); + sqlite3_finalize(ins); + return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +/* Copy any legacy ADR rows from the graph DB's project_summaries into the + * sidecar (INSERT OR IGNORE, so a local row is never clobbered), then set the + * one-time marker. The marker check, copy and marker insert run in ONE + * BEGIN IMMEDIATE transaction on the sidecar, so two processes migrating at + * once cannot re-copy a row the other just deleted. Returns CBM_STORE_ERR on + * any failure (transaction rolled back) so callers can fail closed and preserve + * the old generation. No-op once the marker is set. */ +static int adr_copy_legacy_once(cbm_store_t *s, sqlite3 *adb) { + if (adr_migrated(adb)) { + return CBM_STORE_OK; /* fast path: already migrated, no lock needed */ + } + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + /* Re-check under the write lock: another process may have migrated between + * the fast-path check and acquiring the lock. */ + if (adr_migrated(adb)) { + (void)sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL); + return CBM_STORE_OK; + } + if (adr_copy_rows(s, adb) != CBM_STORE_OK) { + goto fail; + } + if (sqlite3_exec(adb, "INSERT OR IGNORE INTO adr_meta (key, value) VALUES ('migrated','1');", + NULL, NULL, NULL) != SQLITE_OK) { + goto fail; + } + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + goto fail; + } + return CBM_STORE_OK; + +fail: + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; +} + +/* Restore ADRs from the graph DB into the sidecar for any project the sidecar has + * no row for, WITHOUT overwriting a local one, regardless of the `migrated` + * marker. This is what import needs: an artifact carries its ADR in the graph + * DB's project_summaries, and a teammate who already indexed the project (so the + * sidecar is marked, but has no ADR) must still receive it. INSERT OR IGNORE + * guarantees a local ADR is never clobbered; running ungated by the marker is + * what distinguishes this from the one-time legacy migration. It never writes + * the marker: `adb` comes from adr_conn_write, which hands out a sidecar handle + * only after adr_copy_legacy_once committed it, and nothing deletes it. On + * failure the store error names the step (the import logs it). */ +static int adr_restore_if_absent(cbm_store_t *s, sqlite3 *adb) { + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr restore begin"); + return CBM_STORE_ERR; + } + if (adr_copy_rows(s, adb) != CBM_STORE_OK) { + store_set_error(s, "adr restore copy failed"); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; + } + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr restore commit"); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +/* Open (create) the read-write sidecar, configure locking, ensure the schema, + * run the one-time legacy migration, and cache the handle. NULL + error set on + * failure (fail closed). :memory: stores return the graph DB (no race there). */ +static sqlite3 *adr_conn_write(cbm_store_t *s) { + if (!s || !s->db) { + return NULL; + } + if (!s->db_path) { + return s->db; /* :memory: store — ADRs stay in the graph DB */ + } + if (s->adr_db) { + return s->adr_db; + } + char raw[4096]; + char api[4096]; + if (!adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api))) { + store_set_error(s, "adr sidecar path too long"); + return NULL; + } + sqlite3 *adb = NULL; + int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + if (sqlite3_open_v2(api, &adb, flags, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr sidecar open"); + sqlite3_close_v2(adb); + return NULL; + } + adr_configure(adb); + if (adr_ensure_schema(adb) != CBM_STORE_OK) { + store_set_error_sqlite_on(s, adb, "adr sidecar schema"); + sqlite3_close_v2(adb); + return NULL; + } + if (adr_copy_legacy_once(s, adb) != CBM_STORE_OK) { + store_set_error(s, "adr sidecar legacy migration failed"); + sqlite3_close_v2(adb); + return NULL; + } + s->adr_db = adb; + return s->adr_db; +} + +int cbm_store_adr_migrate_once(cbm_store_t *s) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + if (!s->db_path) { + return CBM_STORE_OK; /* :memory: — ADRs already in the graph DB, nothing to move */ + } + return adr_conn_write(s) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + if (!s->db_path) { + return CBM_STORE_OK; /* :memory: — ADR already lives in the graph DB */ + } + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } + return adr_restore_if_absent(s, adb); +} + int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content) { + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } char now[CBM_SZ_32]; iso_now(now, sizeof(now)); @@ -11933,8 +12207,8 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content "ON CONFLICT(project) DO UPDATE SET summary=excluded.summary, " "updated_at=excluded.updated_at"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_store"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_store"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); @@ -11946,22 +12220,17 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR; } -int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { - if (!s || !s->db || !project || !out) { - return CBM_STORE_ERR; - } - memset(out, 0, sizeof(*out)); - - /* ADR storage was added after the original graph schema. A readable - * legacy generation without project_summaries has no ADR to preserve; it - * is not a read failure and must remain replaceable by a full reindex. */ +/* Read one ADR row from an already-open connection into `out`. Returns + * CBM_STORE_OK / CBM_STORE_NOT_FOUND / CBM_STORE_ERR. Never writes. */ +static int adr_read_row(cbm_store_t *s, sqlite3 *adb, const char *project, cbm_adr_t *out) { + /* A readable legacy generation without project_summaries has no ADR; that is + * NOT_FOUND, not a read failure. */ sqlite3_stmt *table_stmt = NULL; int rc = sqlite3_prepare_v2( - s->db, - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", + adb, "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", CBM_NOT_FOUND, &table_stmt, NULL); if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get table probe"); + store_set_error_sqlite_on(s, adb, "adr_get table probe"); return CBM_STORE_ERR; } rc = sqlite3_step(table_stmt); @@ -11970,26 +12239,26 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { return CBM_STORE_NOT_FOUND; } if (rc != SQLITE_ROW || sqlite3_finalize(table_stmt) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get table probe step"); + store_set_error_sqlite_on(s, adb, "adr_get table probe step"); return CBM_STORE_ERR; } const char *sql = "SELECT project, summary, created_at, updated_at FROM project_summaries " "WHERE project=?1"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_get"); return CBM_STORE_ERR; } if (bind_text(stmt, SKIP_ONE, project) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get bind"); + store_set_error_sqlite_on(s, adb, "adr_get bind"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } rc = sqlite3_step(stmt); if (rc != SQLITE_ROW) { if (rc != SQLITE_DONE) { - store_set_error_sqlite(s, "adr_get step"); + store_set_error_sqlite_on(s, adb, "adr_get step"); } sqlite3_finalize(stmt); if (rc == SQLITE_DONE) { @@ -12005,13 +12274,11 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { cbm_store_adr_free(out); - store_set_error_sqlite(s, "adr_get finalize"); + store_set_error_sqlite_on(s, adb, "adr_get finalize"); return CBM_STORE_ERR; } - - /* Every selected column is NOT NULL in the schema. A NULL here therefore - * means either allocation failure or corrupt data; never return a partial - * ADR that a full rebuild could silently drop during publication. */ + /* Every selected column is NOT NULL in the schema. A NULL here means an + * allocation failure or corruption; never return a partial ADR. */ if (!out->project || !out->content || !out->created_at || !out->updated_at) { cbm_store_adr_free(out); store_set_error(s, "adr_get: failed to copy complete ADR"); @@ -12020,16 +12287,62 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { return CBM_STORE_OK; } +int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { + if (!s || !s->db || !project || !out) { + return CBM_STORE_ERR; + } + memset(out, 0, sizeof(*out)); + + /* :memory: store, or a sidecar already open read-write: read it directly. */ + if (!s->db_path) { + return adr_read_row(s, s->db, project, out); + } + if (s->adr_db) { + return adr_read_row(s, s->adr_db, project, out); + } + + /* A read must never create the sidecar (a query-only cache may be + * read-only): open it READONLY if the file exists, otherwise read the + * legacy row from the graph DB without migrating. */ + char raw[4096]; + char api[4096]; + if (adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api)) && cbm_file_exists(raw)) { + sqlite3 *ro = NULL; + if (sqlite3_open_v2(api, &ro, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, ro, "adr sidecar open (ro)"); + sqlite3_close_v2(ro); + return CBM_STORE_ERR; + } + (void)sqlite3_exec(ro, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL); + /* A sidecar that exists but was never marked migrated is half-created: + * the legacy copy did not commit, so the ADR (if any) still lives only + * in the graph DB. Fall back to the graph row so a partial sidecar never + * hides an existing ADR. */ + if (!adr_migrated(ro)) { + sqlite3_close_v2(ro); + return adr_read_row(s, s->db, project, out); + } + int rc = adr_read_row(s, ro, project, out); + sqlite3_close_v2(ro); + return rc; + } + return adr_read_row(s, s->db, project, out); +} + int cbm_store_adr_delete(cbm_store_t *s, const char *project) { + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } const char *sql = "DELETE FROM project_summaries WHERE project=?1"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_delete"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_delete"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); int rc = sqlite3_step(stmt); - int changes = sqlite3_changes(s->db); + int changes = sqlite3_changes(adb); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { return CBM_STORE_ERR; @@ -12047,13 +12360,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha return CBM_STORE_ERR; } - /* The read-modify-write below must be ONE transaction. Three writers - * replace this row wholesale — the indexing pipeline, the UI POST - * /api/adr handler, and manage_adr mode='update' — so an unguarded - * get/merge/store silently loses whichever of them commits between the - * read and the UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a - * competing writer waits rather than being overwritten. */ - if (cbm_store_begin(s) != CBM_STORE_OK) { + /* The read-modify-write below must be ONE transaction, and it runs on the + * ADR connection (the sidecar for a file store, the graph DB for :memory:) + * — the same connection cbm_store_adr_get/store use, so the write lock + * actually covers them. Two writers replace this row wholesale — the UI + * POST /api/adr handler and manage_adr mode='update' — so an unguarded + * get/merge/store silently loses whichever commits between the read and the + * UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a competing + * writer waits rather than being overwritten. */ + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_update begin"); return CBM_STORE_ERR; } @@ -12061,7 +12381,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha cbm_adr_t existing; int rc = cbm_store_adr_get(s, project, &existing); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, "no existing ADR to update"); return rc; } @@ -12076,7 +12396,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha if (cbm_adr_check_structure(existing.content, structure_err, (int)sizeof(structure_err)) != CBM_STORE_OK) { cbm_store_adr_free(&existing); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, structure_err); return CBM_STORE_ERR; } @@ -12090,7 +12410,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha } if (!merged) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, "failed to splice ADR section"); return CBM_STORE_ERR; } @@ -12101,7 +12421,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha snprintf(msg, sizeof(msg), "merged ADR exceeds %d chars (%d chars)", CBM_ADR_MAX_LENGTH, (int)strlen(merged)); free(merged); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, msg); return CBM_STORE_ERR; } @@ -12110,19 +12430,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha rc = cbm_store_adr_store(s, project, merged); free(merged); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return rc; } /* Read back INSIDE the transaction so `out` is exactly what commits. */ rc = cbm_store_adr_get(s, project, out); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return rc; } - if (cbm_store_commit(s) != CBM_STORE_OK) { + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_update commit"); cbm_store_adr_free(out); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return CBM_STORE_ERR; } return CBM_STORE_OK; diff --git a/src/store/store.h b/src/store/store.h index 63379ad94..eed6f746d 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -1163,6 +1163,17 @@ typedef struct { int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content); int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out); +/* Move any legacy ADR row from the graph DB's project_summaries into the + * per-project sidecar, once (guarded by a marker in the sidecar). Call before a + * rebuild deletes the old generation, and on artifact import. Returns + * CBM_STORE_ERR on failure so callers can preserve the old generation. */ +int cbm_store_adr_migrate_once(cbm_store_t *s); +/* Restore ADRs from the graph DB's project_summaries into the per-project sidecar + * for any project the sidecar has no row for, without overwriting a local one and + * regardless of the migrated marker. Used on artifact import so a teammate who + * already indexed the project (marked sidecar, no ADR) still receives the shared + * ADR the artifact carries. Returns CBM_STORE_ERR on failure. */ +int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s); int cbm_store_adr_delete(cbm_store_t *s, const char *project); int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const char **keys, const char **values, int count, cbm_adr_t *out); diff --git a/tests/test_artifact.c b/tests/test_artifact.c index 8bbec658d..abf1415da 100644 --- a/tests/test_artifact.c +++ b/tests/test_artifact.c @@ -2,6 +2,7 @@ * test_artifact.c — Tests for persistent artifact export/import. */ #include "test_framework.h" +#include "sqlite3.h" /* vendored/sqlite3 — seed a legacy project_summaries row in ADR tests */ #include "store/store.h" #include "pipeline/artifact.h" #include "pipeline/pipeline.h" @@ -64,6 +65,21 @@ static void cleanup_dir(const char *path) { (void)system(cmd); } +/* Remove a destination graph DB (+ its -wal/-shm/-journal) while leaving the ADR + * sidecar ".adr.db" in place. Import only runs when the graph DB is missing + * (try_artifact_bootstrap); this mirrors that shape so the import rename does not + * hit an existing file (which Windows refuses, unlike POSIX). */ +static void remove_graph_db_keep_sidecar(const char *db) { + char p[1200]; + (void)cbm_unlink(db); + snprintf(p, sizeof(p), "%s-wal", db); + (void)cbm_unlink(p); + snprintf(p, sizeof(p), "%s-shm", db); + (void)cbm_unlink(p); + snprintf(p, sizeof(p), "%s-journal", db); + (void)cbm_unlink(p); +} + static void write_text_file(const char *path, const char *text) { FILE *fp = fopen(path, "w"); if (!fp) { @@ -206,6 +222,226 @@ TEST(artifact_export_fast_roundtrip) { PASS(); } +/* The ADR travels with the exported artifact: it lives in the ".adr.db" + * sidecar, and export must carry it into graph.db.zst so an import restores it + * into the destination's sidecar (round-2 review item 7d). */ +TEST(artifact_export_roundtrip_keeps_adr) { + setup_artifact_test(); + create_test_db(g_db); + + const char *adr_text = "# Decision\nThe team chose the sidecar design."; + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", adr_text), CBM_STORE_OK); + cbm_store_close(src); + + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* The imported project starts with the team's ADR (restored into the + * destination sidecar by import). */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + /* The fresh destination sidecar is marked migrated after the import, so a + * later reindex's migrate-before-delete is a no-op. Opening the sidecar for + * the restore already commits the marker; the restore itself adds none. */ + char side[1100]; + snprintf(side, sizeof(side), "%s.adr.db", import_db); + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open_v2(side, &raw, SQLITE_OPEN_READONLY, NULL), SQLITE_OK); + sqlite3_stmt *st = NULL; + ASSERT_EQ( + sqlite3_prepare_v2(raw, "SELECT value FROM adr_meta WHERE key='migrated';", -1, &st, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(st), SQLITE_ROW); + sqlite3_finalize(st); + sqlite3_close(raw); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* Regression: a stale legacy `project_summaries` row that lingers in the graph DB + * after its ADR was migrated then DELETED must NOT resurrect through an export. + * With the sidecar empty, the snapshot must ship an empty table, so an import + * finds no ADR. (Before the DELETE-first fix, VACUUM INTO copied the stale row + * and import restored a deleted decision record.) */ +TEST(artifact_export_without_adr_does_not_resurrect_legacy_row) { + setup_artifact_test(); + create_test_db(g_db); + + /* Pre-sidecar layout: an ADR lingering only in the graph DB. */ + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(g_db, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + "test-proj", "# Decision\nstale, later deleted"); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* Migrate it into the sidecar (marker set), then DELETE it. The graph row + * still lingers; the sidecar is now migrated-but-empty. */ + cbm_store_t *s = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_adr_migrate_once(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_delete(s, "test-proj"), CBM_STORE_OK); + cbm_store_close(s); + + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* An import must not clobber an ADR already present locally: the restore uses + * INSERT OR IGNORE, so the teammate's own decision record survives importing a + * shared artifact. */ +TEST(artifact_import_does_not_clobber_local_adr) { + setup_artifact_test(); + create_test_db(g_db); + + /* The shared artifact carries ADR "A". */ + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + /* The importing machine already has its own local ADR "B" for this project. */ + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + cbm_store_t *local = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(local); + ASSERT_EQ(cbm_store_adr_store(local, "test-proj", "# Decision\nlocal B"), CBM_STORE_OK); + cbm_store_close(local); + + /* Import only runs when the graph DB is missing (try_artifact_bootstrap), and + * Windows cannot rename over an existing file. Remove the destination graph DB + * (keeping the sidecar + its marker) to match production. */ + remove_graph_db_keep_sidecar(import_db); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* Local B is preserved — the import did not overwrite it with shared A. */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, "# Decision\nlocal B"); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* The shared ADR must reach a teammate who has ALREADY indexed the project: their + * sidecar is marked migrated but has no ADR row, so the marker-gated migrate-once + * would skip it. Import restores-if-absent (ungated), so the artifact's ADR "A" + * arrives. (On main, import replaced the whole DB, so the ADR always came through; + * the sidecar must preserve that.) */ +TEST(artifact_import_restores_adr_when_local_absent) { + setup_artifact_test(); + create_test_db(g_db); + + /* The shared artifact carries ADR "A". */ + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + /* The destination is already indexed: a marked sidecar with NO ADR row. */ + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + create_test_db(import_db); + cbm_store_t *pre = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(pre); + ASSERT_EQ(cbm_store_adr_migrate_once(pre), CBM_STORE_OK); /* marks the sidecar, no ADR */ + cbm_adr_t none = {0}; + ASSERT_EQ(cbm_store_adr_get(pre, "test-proj", &none), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&none); + cbm_store_close(pre); + + /* Import only runs when the graph DB is missing; remove it (keep the marked + * sidecar) so the rename succeeds on Windows too. */ + remove_graph_db_keep_sidecar(import_db); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* The shared ADR "A" is restored despite the pre-existing marker. */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, "# Decision\nshared A"); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* A failed ADR restore must not be silent (#2315 follow-up). A DIRECTORY at + * ".adr.db" makes the sidecar open fail deterministically (no hook). The + * graph DB is already installed when the restore runs, so the import still + * succeeds (best-effort, unchanged), but the team ADR that did not arrive is + * logged with the store's reason instead of being dropped without a trace. */ +TEST(artifact_import_logs_failed_adr_restore) { + setup_artifact_test(); + create_test_db(g_db); + + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + char adr_dir[1100]; + snprintf(adr_dir, sizeof(adr_dir), "%s.adr.db", import_db); + ASSERT_EQ(cbm_mkdir(adr_dir), 0); + + capture_logs_start(); + int rc = cbm_artifact_import(g_repo, import_db); + const char *logs = capture_logs_end(); + + ASSERT_EQ(rc, 0); /* the graph DB is installed; the restore stays best-effort */ + ASSERT_TRUE(cbm_file_exists(import_db)); + const char *line = strstr(logs, "level=warn msg=artifact.import err=adr_restore"); + ASSERT_NOT_NULL(line); + /* The store's reason is carried (the text log format maps spaces to '_'). */ + ASSERT(strstr(line, "detail=adr_sidecar_open") != NULL); + + cleanup_dir(g_tmpdir); + PASS(); +} + TEST(artifact_export_best_roundtrip) { setup_artifact_test(); create_test_db(g_db); @@ -1172,6 +1408,11 @@ SUITE(artifact) { RUN_TEST(artifact_repo_path_shell_safe_rejects_injection); RUN_TEST(artifact_repo_path_shell_safe_rejects_cmd_metachars_on_windows); RUN_TEST(artifact_export_fast_roundtrip); + RUN_TEST(artifact_export_roundtrip_keeps_adr); + RUN_TEST(artifact_export_without_adr_does_not_resurrect_legacy_row); + RUN_TEST(artifact_import_does_not_clobber_local_adr); + RUN_TEST(artifact_import_restores_adr_when_local_absent); + RUN_TEST(artifact_import_logs_failed_adr_restore); RUN_TEST(artifact_export_best_roundtrip); RUN_TEST(artifact_exists_check); RUN_TEST(artifact_commit_hash); diff --git a/tests/test_cli.c b/tests/test_cli.c index d4c729f1f..a5d327fc3 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -6180,6 +6180,149 @@ TEST(cli_zed_mcp_jsonc_comments) { PASS(); } +#ifndef _WIN32 +TEST(cli_install_agent_config_symlinks_issue2671) { + /* Refs #2671: dotfiles checkout pattern where agent config directory is a + * user-owned symlink into dotfiles under the user's home directory. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-symlink-test-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char dotfiles_zed[512]; + snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir); + test_mkdirp(dotfiles_zed); + char dotfiles_settings[512]; + snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed); + write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n"); + +#ifdef __APPLE__ + char parent_dir[512]; + snprintf(parent_dir, sizeof(parent_dir), "%s/Library/Application Support", tmpdir); + test_mkdirp(parent_dir); + char zed_link[512]; + snprintf(zed_link, sizeof(zed_link), "%s/Zed", parent_dir); +#else + char parent_dir[512]; + snprintf(parent_dir, sizeof(parent_dir), "%s/.config", tmpdir); + test_mkdirp(parent_dir); + char zed_link[512]; + snprintf(zed_link, sizeof(zed_link), "%s/zed", parent_dir); +#endif + + char *saved_xdg = save_test_env("XDG_CONFIG_HOME"); + char xdg_dir[640]; + snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir); + cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */ + + /* Dangling symlink and symlink to a regular file must remain undetected. */ + char dangling_target[512]; + snprintf(dangling_target, sizeof(dangling_target), "%s/dotfiles/missing-zed", tmpdir); + ASSERT_EQ(symlink(dangling_target, zed_link), 0); + bool dangling_detected = cbm_detect_agents(tmpdir).zed; + ASSERT_EQ(cbm_unlink(zed_link), 0); + + ASSERT_EQ(symlink(dotfiles_settings, zed_link), 0); + bool file_link_detected = cbm_detect_agents(tmpdir).zed; + ASSERT_EQ(cbm_unlink(zed_link), 0); + + /* Symlink resolving to a directory not owned by the invoking user must be refused. */ + char foreign_dir[512]; + const char *unowned_target = "/"; + if (geteuid() == 0) { + snprintf(foreign_dir, sizeof(foreign_dir), "%s/dotfiles/foreign-zed", tmpdir); + test_mkdirp(foreign_dir); + ASSERT_EQ(chown(foreign_dir, 65534, 65534), 0); + unowned_target = foreign_dir; + } + ASSERT_EQ(symlink(unowned_target, zed_link), 0); + bool unowned_target_detected = cbm_detect_agents(tmpdir).zed; + ASSERT_EQ(cbm_unlink(zed_link), 0); + + ASSERT_EQ(symlink(dotfiles_zed, zed_link), 0); + + cbm_cli_set_client_selection_for_testing("zed"); + int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false); + cbm_cli_set_client_selection_for_testing(NULL); + restore_test_env("XDG_CONFIG_HOME", saved_xdg); + + char *after = read_test_file_alloc(dotfiles_settings); + bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL; + bool has_original = after && strstr(after, "Ayu Dark") != NULL; + free(after); + + struct stat st; + ASSERT_EQ(lstat(zed_link, &st), 0); + bool still_symlink = S_ISLNK(st.st_mode); + + test_rmdir_r(tmpdir); + ASSERT(!dangling_detected); + ASSERT(!file_link_detected); + ASSERT(!unowned_target_detected); + ASSERT_EQ(rc, 0); + ASSERT(has_cbm); + ASSERT(has_original); + ASSERT(still_symlink); + PASS(); +} + +TEST(cli_install_agent_config_file_symlink_issue2671) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-file-symlink-test-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char dotfiles_zed[512]; + snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir); + test_mkdirp(dotfiles_zed); + char dotfiles_settings[512]; + snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed); + write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n"); + +#ifdef __APPLE__ + char zed_dir[512]; + snprintf(zed_dir, sizeof(zed_dir), "%s/Library/Application Support/Zed", tmpdir); + test_mkdirp(zed_dir); + char zed_settings_link[512]; + snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir); + ASSERT_EQ(symlink("../../../dotfiles/zed/settings.json", zed_settings_link), 0); +#else + char zed_dir[512]; + snprintf(zed_dir, sizeof(zed_dir), "%s/.config/zed", tmpdir); + test_mkdirp(zed_dir); + char zed_settings_link[512]; + snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir); + ASSERT_EQ(symlink("../../dotfiles/zed/settings.json", zed_settings_link), 0); +#endif + + char *saved_xdg = save_test_env("XDG_CONFIG_HOME"); + char xdg_dir[640]; + snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir); + cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */ + + cbm_cli_set_client_selection_for_testing("zed"); + int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false); + cbm_cli_set_client_selection_for_testing(NULL); + restore_test_env("XDG_CONFIG_HOME", saved_xdg); + + char *after = read_test_file_alloc(dotfiles_settings); + bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL; + bool has_original = after && strstr(after, "Ayu Dark") != NULL; + free(after); + + struct stat st; + ASSERT_EQ(lstat(zed_settings_link, &st), 0); + bool still_symlink = S_ISLNK(st.st_mode); + + test_rmdir_r(tmpdir); + ASSERT_EQ(rc, 0); + ASSERT(has_cbm); + ASSERT(has_original); + ASSERT(still_symlink); + PASS(); +} +#endif + /* ═══════════════════════════════════════════════════════════════════ * PATH management tests (port of TestCLI_InstallPATHAppend) * ═══════════════════════════════════════════════════════════════════ */ @@ -19250,6 +19393,10 @@ SUITE(cli) { RUN_TEST(cli_zed_mcp_preserves_settings); RUN_TEST(cli_zed_mcp_uninstall); RUN_TEST(cli_zed_mcp_jsonc_comments); +#ifndef _WIN32 + RUN_TEST(cli_install_agent_config_symlinks_issue2671); + RUN_TEST(cli_install_agent_config_file_symlink_issue2671); +#endif /* PATH management (3 tests) */ RUN_TEST(cli_ensure_path_append); diff --git a/tests/test_conditional_variants.c b/tests/test_conditional_variants.c index 5d5aa1716..a8ced098c 100644 --- a/tests/test_conditional_variants.c +++ b/tests/test_conditional_variants.c @@ -402,6 +402,45 @@ TEST(cv_elixir_compile_time_if) { PASS(); } +/* A multi-clause function under a compile-time `if`. Each branch's clauses + * fold into one definition spanning them (the clause fold), and the two + * branches stay two variants of one identity: exactly the spans 5-6 and 8-9, + * neither one span per clause (no fold) nor one span over both branches (a + * fold across the branch boundary). */ +TEST(cv_elixir_multi_clause_under_if) { + static const cv_file_t files[] = {{"pick.ex", "defmodule Pick do\n" /* 1 */ + " def alpha(_), do: :ok\n" /* 2 */ + " def beta(_), do: :ok\n" /* 3 */ + " if Code.ensure_loaded?(:win32) do\n" /* 4 */ + " def pick(0), do: alpha(0)\n" /* 5 */ + " def pick(n), do: alpha(n)\n" /* 6 */ + " else\n" /* 7 */ + " def pick(0), do: beta(0)\n" /* 8 */ + " def pick(n), do: beta(n)\n" /* 9 */ + " end\n" /* 10 */ + " def caller, do: pick(1)\n" /* 11 */ + "end\n"}}; /* 12 */ + cv_proj_t p; + ASSERT_TRUE(cv_index(&p, files, 1)); + cv_result_t r = cv_measure(&p, "pick", "alpha", "beta", "caller"); + const char *span_do = + "SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v " + "WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS + " AND json_extract(v.value,'$.start_line')=5 AND json_extract(v.value,'$.end_line')=6"; + const char *span_else = + "SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v " + "WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS + " AND json_extract(v.value,'$.start_line')=8 AND json_extract(v.value,'$.end_line')=9"; + int do_spans = cv_count(&p, span_do, "pick", NULL); + int else_spans = cv_count(&p, span_else, "pick", NULL); + cv_cleanup(&p); + ASSERT_TRUE(cv_expect("elixir_multi_clause", r)); + ASSERT_EQ(r.variant_spans, 2); + ASSERT_EQ(do_spans, 1); + ASSERT_EQ(else_spans, 1); + PASS(); +} + /* Verilog: the identity only. Module instantiation produces no CALLS edge in * this graph even without any `ifdef (a general Verilog gap, not a variant * one), so the body and caller edges are not this suite's to assert. */ @@ -798,6 +837,7 @@ SUITE(conditional_variants) { RUN_TEST(cv_pascal_ifdef); RUN_TEST(cv_julia_static_if); RUN_TEST(cv_elixir_compile_time_if); + RUN_TEST(cv_elixir_multi_clause_under_if); RUN_TEST(cv_verilog_ifdef); RUN_TEST(cv_c_platform_files); RUN_TEST(cv_c_platform_directories); diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 9d318f8c0..0bf14c68b 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -944,10 +944,201 @@ TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) { PASS(); } +static bool cross_repo_seed_server_route(const cross_repo_fixture_t *fixture, + const char *target_project, const char *method, + const char *route_path, const char *handler_name) { + char target_path[512]; + if (!cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) { + return false; + } + cbm_store_t *target = cbm_store_open_path(target_path); + if (!target) { + return false; + } + bool ok = cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK; + char target_route_qn[256]; + char handler_qn[256]; + char route_name[128]; + snprintf(target_route_qn, sizeof(target_route_qn), "__route__%s__%s", method, route_path); + snprintf(handler_qn, sizeof(handler_qn), "%s.%s", target_project, handler_name); + snprintf(route_name, sizeof(route_name), "%s %s", method, route_path); + + cbm_node_t target_route = {.project = target_project, + .label = "Route", + .name = route_name, + .qualified_name = target_route_qn, + .file_path = "server.java"}; + cbm_node_t handler = {.project = target_project, + .label = "Function", + .name = handler_name, + .qualified_name = handler_qn, + .file_path = "server.java"}; + int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; + int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; + cbm_edge_t handles = {.project = target_project, + .source_id = handler_id, + .target_id = target_route_id, + .type = "HANDLES"}; + ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; + cbm_store_close(target); + return ok; +} + +static bool cross_repo_seed_client_call(const cross_repo_fixture_t *fixture, + const char *source_project, const char *caller_name, + const char *callee_name, const char *url_path, + const char *method) { + char source_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path(source_path); + if (!source) { + return false; + } + bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK; + char caller_qn[256]; + char local_route_qn[256]; + char edge_props[512]; + snprintf(caller_qn, sizeof(caller_qn), "%s.%s", source_project, caller_name); + snprintf(local_route_qn, sizeof(local_route_qn), "__route__%s__%s", method ? method : "ANY", + url_path); + if (method) { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"method\":\"%s\"}", callee_name, + url_path, method); + } else { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\"}", callee_name, url_path); + } + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = caller_name, + .qualified_name = caller_qn, + .file_path = "item.service.ts"}; + cbm_node_t local_route = {.project = source_project, + .label = "Route", + .name = url_path, + .qualified_name = local_route_qn, + .file_path = "item.service.ts"}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0; + cbm_edge_t http_call = {.project = source_project, + .source_id = caller_id, + .target_id = local_route_id, + .type = "HTTP_CALLS", + .properties_json = edge_props}; + ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0; + cbm_store_close(source); + return ok; +} + +static int cross_repo_count_edges_like(const cross_repo_fixture_t *fixture, const char *project, + const char *edge_type, const char *pattern) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return -1; + } + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) { + return -1; + } + sqlite3_stmt *s = NULL; + int count = -1; + if (sqlite3_prepare_v2( + cbm_store_get_db(store), + "SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2 AND properties LIKE ?3", + -1, &s, NULL) == SQLITE_OK) { + sqlite3_bind_text(s, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 2, edge_type, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 3, pattern, -1, SQLITE_STATIC); + if (sqlite3_step(s) == SQLITE_ROW) { + count = sqlite3_column_int(s, 0); + } + sqlite3_finalize(s); + } + cbm_store_close(store); + return count; +} + +/* #2768: Angular HttpClient calls must match cross-repo routes with exact HTTP verbs. + * When client makes GET requests to /items/list, /items/42, and /items/job/10, + * and the server defines overlapping routes: + * DELETE /items/{id} (seeded first in DB) + * GET /items/{id} (seeded second in DB) + * POST /items/{parentId}/{id} (seeded first in DB) + * GET /items/job/{jobId} (seeded second in DB) + * the fuzzy route matcher must enforce method compatibility, linking the GET calls + * to their respective GET endpoints and never to DELETE or POST endpoints. + * getItem and getJob are seeded WITHOUT a method property (an edge written by an + * index that did not record the verb): the matcher must infer GET from the + * callee's verb suffix, or the method-less fuzzy match lands on the DELETE / POST + * route seeded first. getItems keeps its explicit "GET". */ +TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + /* Target server with overlapping endpoints */ + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/list", + "handle_list_items") && + cross_repo_seed_server_route(&fixture, "api-2768", "DELETE", "/items/{}", + "handle_delete_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/{}", + "handle_get_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "POST", "/items/{}/{}", + "handle_post_nested_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/job/{}", + "handle_get_job") && + /* Angular client making GET requests */ + cross_repo_seed_client_call(&fixture, "angular-2768", "getItems", + "this.http.get", "/items/list", "GET") && + cross_repo_seed_client_call(&fixture, "angular-2768", "getItem", + "this.http.get", "/items/42", NULL) && + cross_repo_seed_client_call(&fixture, "angular-2768", "getJob", + "this.http.get", "/items/job/10", NULL); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed issue 2768 fixture"); + } + + const char *target = "api-2768"; + cbm_cross_repo_result_t fwd = cbm_cross_repo_match("angular-2768", &target, 1); + ASSERT_FALSE(fwd.failed); + ASSERT_EQ(fwd.http_edges, 3); + + /* Assert that client CROSS_HTTP_CALLS edges are linked to GET handlers, not DELETE or POST */ + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_delete_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_post_nested_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_list_items%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_item%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_job%"), + 1); + + /* Reverse pass */ + const char *consumer = "angular-2768"; + cbm_cross_repo_result_t rev = cbm_cross_repo_match("api-2768", &consumer, 1); + ASSERT_FALSE(rev.failed); + ASSERT_EQ(rev.http_edges, 3); + + cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); + PASS(); +} + SUITE(cross_repo) { RUN_TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459); RUN_TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459); RUN_TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459); + RUN_TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768); RUN_TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609); RUN_TEST(cross_repo_null_target_fails_without_dereference); RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 0a15bc48a..541b7bb2d 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -100,6 +100,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++) { @@ -1896,6 +1937,730 @@ 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(); +} + +/* A guard hides the head from the scope walk and from the calls walk. + * compute_elixir_func_qn accepts only a `call` or an `identifier` as a def's + * first argument, so a guarded clause resolved no QN and opened NO function + * scope: every call in its body was attributed to the FILE node, the function + * reported no outgoing edges, and its callees gained an in-edge from the file + * instead of from their caller. A paren-less guarded clause (`def f when g`) is + * worse still: it has no inner call node at all, so the guard operator itself + * falls through to extract_callee_name's first-identifier last resort and mints + * a phantom CALLS edge naming the function being defined. */ +TEST(extract_elixir_guarded_def_head_scope) { + CBMFileResult *r = extract("defmodule Guards do\n" + " def target(x), do: x\n" + " def other(x), do: x\n" + " def unguarded_caller(a), do: target(a)\n" + " def guarded_caller(a) when is_binary(a) do\n" + " target(a)\n" + " other(a)\n" + " end\n" + " def bare_guarded when true, do: target(:ok)\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guard_scope.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* A paren-less guarded head is a definition, never a call to itself. */ + ASSERT_EQ(count_calls_named(r, "bare_guarded"), 0); + + /* Both calls in the guarded body source to the guarded function, and the + * paren-less clause's body call to that clause. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.guarded_caller"), 1); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope.guarded_caller"), 1); + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.bare_guarded"), 1); + + /* Control on the path the fix does not touch: an unguarded clause still + * sources its body call to itself. This is NOT a leak detector -- the walk + * expires scopes by tree depth (pop_expired_scopes), so a sibling clause + * always unwinds the previous one before pushing its own and "pushed but + * never popped" is not a state this walk can reach. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.unguarded_caller"), 1); + + /* Nothing falls back to the file node. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope"), 0); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope"), 0); + + cbm_free_result(r); + PASS(); +} + +/* The half the calls and scope assertions above cannot see: the usages walk + * asks whether an identifier sits inside the def's SIGNATURE, and treats + * everything that does as part of the binding rather than as a reference. A + * guard makes that signature the whole `when` operator, which spans the guard + * expression too -- so `a` read by `is_binary(a)` was classified as its own + * binding and emitted no USAGE row at all. A guard is an expression, not a + * pattern: the parameters are bound by `guarded(a, b)` and every mention of one + * to its right is a read, exactly as the same mention in the body already is. + * Unwrapping the signature to the head restores those rows without admitting + * the binding sites themselves, which is why each name is asserted both within + * its scope and in total. */ +TEST(extract_elixir_guarded_def_head_guard_usages) { + CBMFileResult *r = extract("defmodule GuardUsage do\n" + " def plain(x), do: x\n" + " def guarded(a, b) when is_binary(a) and byte_size(a) > 3 do\n" + " b\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guard_usage.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* Both guard reads of the parameter are usages of the guarded clause. */ + ASSERT_EQ(count_usages_from(r, "a", "t.guard_usage.guarded"), 2); + /* ...and nothing else claims `a`: the `a` in `guarded(a, b)` binds it. */ + ASSERT_EQ(count_usages_named(r, "a"), 2); + + /* The body read is unchanged, and the head's `b` is still a binding -- + * an unwrap that took the guard instead of the head would add both head + * parameters here. */ + ASSERT_EQ(count_usages_from(r, "b", "t.guard_usage.guarded"), 1); + ASSERT_EQ(count_usages_named(r, "b"), 1); + + /* Control: an unguarded clause was never affected either way. */ + ASSERT_EQ(count_usages_from(r, "x", "t.guard_usage.plain"), 1); + ASSERT_EQ(count_usages_named(r, "x"), 1); + + cbm_free_result(r); + PASS(); +} + +/* Every clause of an Elixir function is its own `def` call, and an Elixir QN + * carries neither module nor arity, so all clauses compute one qualified name + * and collide on a single graph node. cbm_gbuf_upsert_node ranks a same-QN + * collision by LARGEST start_line, so the survivor was the last clause in the + * file and get_code_snippet returned that clause as the whole function: a + * two-clause `admin?` read back as `def admin?(%__MODULE__{}), do: false` -- + * the graph asserting a predicate that always returns false. The node has to + * span its own clauses, and ONLY its own: the fold is bounded to the + * immediately preceding definition and to the enclosing module body, so a def + * of another name ends the group and two same-named functions in two modules + * stay two nodes. */ +TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module) { + CBMFileResult *r = extract("defmodule Roles do\n" /* 1 */ + " def admin?(%{role: :admin}), do: true\n" /* 2 */ + " def admin?(%{}), do: false\n" /* 3 */ + "\n" /* 4 */ + " def promote(user), do: user\n" /* 5 */ + "\n" /* 6 */ + " def admin?(_other), do: false\n" /* 7 */ + "end\n" /* 8 */ + "\n" /* 9 */ + "defmodule Guests do\n" /* 10 */ + " def admin?(:root), do: true\n" /* 11 */ + " def admin?(_any), do: false\n" /* 12 */ + "end\n", /* 13 */ + CBM_LANG_ELIXIR, "t", "roles.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* The two adjacent Roles clauses fold; the clause past `promote` is its own + * group; the Guests pair is a DIFFERENT function and stays its own node. */ + ASSERT_EQ(3, count_defs_named(r, "Function", "admin?")); + ASSERT_EQ(1, count_defs_named(r, "Function", "promote")); + + const CBMDefinition *admin[3] = {NULL, NULL, NULL}; + const CBMDefinition *promote = NULL; + int seen = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") != 0) { + continue; + } + if (strcmp(r->defs.items[i].name, "admin?") == 0 && seen < 3) { + admin[seen++] = &r->defs.items[i]; + } else if (strcmp(r->defs.items[i].name, "promote") == 0) { + promote = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(admin[0]); + ASSERT_NOT_NULL(admin[1]); + ASSERT_NOT_NULL(admin[2]); + ASSERT_NOT_NULL(promote); + + ASSERT_EQ(2, (int)admin[0]->start_line); + ASSERT_EQ(3, (int)admin[0]->end_line); + /* separated from lines 2-3 by `promote`, so it keeps its own span */ + ASSERT_EQ(7, (int)admin[1]->start_line); + ASSERT_EQ(7, (int)admin[1]->end_line); + /* Guests.admin? never reaches back over the module boundary */ + ASSERT_EQ(11, (int)admin[2]->start_line); + ASSERT_EQ(12, (int)admin[2]->end_line); + ASSERT_EQ(5, (int)promote->start_line); + ASSERT_EQ(5, (int)promote->end_line); + cbm_free_result(r); + PASS(); +} + +/* A nested module is the one place where the "def of another name ends the + * group" rule is not enough: an Elixir QN carries no module, so `Outer.run` and + * `Outer.Inner.run` compute the SAME qualified name, and when Inner sits + * directly above Outer's own clause the two are adjacent in the extracted defs. + * The fold is bounded to the module body as well, so they stay two nodes. */ +TEST(extract_elixir_nested_module_clauses_do_not_fold) { + CBMFileResult *r = extract("defmodule Outer do\n" /* 1 */ + " defmodule Inner do\n" /* 2 */ + " def run(a), do: a\n" /* 3 */ + " end\n" /* 4 */ + "\n" /* 5 */ + " def run(b), do: b + 1\n" /* 6 */ + "end\n", /* 7 */ + CBM_LANG_ELIXIR, "t", "nested.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(2, count_defs_named(r, "Function", "run")); + + const CBMDefinition *runs[2] = {NULL, NULL}; + int seen = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") == 0 && + strcmp(r->defs.items[i].name, "run") == 0 && seen < 2) { + runs[seen++] = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(runs[0]); + ASSERT_NOT_NULL(runs[1]); + ASSERT_EQ(3, (int)runs[0]->start_line); + ASSERT_EQ(3, (int)runs[0]->end_line); + ASSERT_EQ(6, (int)runs[1]->start_line); + ASSERT_EQ(6, (int)runs[1]->end_line); + cbm_free_result(r); + PASS(); +} + +/* Folding clauses has to decide is_exported for the merged node. A `def` clause + * and a `defp` clause of the same name AND arity are clauses of one function -- + * a private guard clause or a nil short-circuit written above the public head + * is ordinary Elixir -- so is_exported is the OR over the folded clauses: the + * name is callable from outside if any clause exports it, and a wholly private + * function stays unexported. */ +TEST(extract_elixir_folded_clause_is_exported_is_the_or) { + CBMFileResult *r = extract("defmodule Acc do\n" /* 1 */ + " def build(l), do: build(l)\n" /* 2 */ + " defp build([]), do: []\n" /* 3 */ + "\n" /* 4 */ + " defp trim(nil), do: nil\n" /* 5 */ + " defp trim(s), do: s\n" /* 6 */ + "end\n", /* 7 */ + CBM_LANG_ELIXIR, "t", "acc.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_defs_named(r, "Function", "build")); + ASSERT_EQ(1, count_defs_named(r, "Function", "trim")); + + const CBMDefinition *build = NULL; + const CBMDefinition *trim = NULL; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") != 0) { + continue; + } + if (strcmp(r->defs.items[i].name, "build") == 0) { + build = &r->defs.items[i]; + } else if (strcmp(r->defs.items[i].name, "trim") == 0) { + trim = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(build); + ASSERT_NOT_NULL(trim); + ASSERT_EQ(2, (int)build->start_line); + ASSERT_EQ(3, (int)build->end_line); + ASSERT_TRUE(build->is_exported); + ASSERT_EQ(5, (int)trim->start_line); + ASSERT_EQ(6, (int)trim->end_line); + ASSERT_FALSE(trim->is_exported); + cbm_free_result(r); + + /* The other direction, and the only one the `prev->is_exported ||` disjunct + * decides. Above, the exporting clause is the FIRST one, so a plain + * `prev->is_exported = is_exported` assignment would also have to lose it + * on the later defp -- but a last-clause-wins assignment happens to be + * right whenever the def comes last. Here the defp comes first and the def + * second, so the surviving flag can only be true if the fold ORs rather + * than assigns. */ + CBMFileResult *o = extract("defmodule Ord do\n" /* 1 */ + " defp z(1), do: 1\n" /* 2 */ + " def z(2), do: 2\n" /* 3 */ + "end\n", /* 4 */ + CBM_LANG_ELIXIR, "t", "ord.ex"); + ASSERT_NOT_NULL(o); + ASSERT_FALSE(o->has_error); + ASSERT_EQ(1, count_defs_named(o, "Function", "z")); + const CBMDefinition *z = NULL; + for (int i = 0; i < o->defs.count; i++) { + if (strcmp(o->defs.items[i].label, "Function") == 0 && + strcmp(o->defs.items[i].name, "z") == 0) { + z = &o->defs.items[i]; + } + } + ASSERT_NOT_NULL(z); + ASSERT_EQ(2, (int)z->start_line); + ASSERT_EQ(3, (int)z->end_line); + ASSERT_TRUE(z->is_exported); + cbm_free_result(o); + PASS(); +} + +/* First definition with this name, for the Elixir clause-fold tests below. + * find_def() is declared much further down this file. */ +static const CBMDefinition *elixir_first_def(CBMFileResult *r, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0) { + return &r->defs.items[i]; + } + } + return NULL; +} + +/* A guarded clause head is a DECLARATION of the name, never a call to it, and + * the call extractor has to agree with the def extractor about which node the + * head is. It did not: it suppressed a head only when the head node IS the + * def's first named argument, but `def f(x) when g` parses its whole head as a + * `when` binary_operator, so the inner `f(x)` was not that argument and was + * recorded as a call to `f`. Under the folded span that phantom lands inside + * the function's own node, and self-recursion is decided by line containment, + * so an ordinary two-clause guarded function reported itself recursive. + * Recursion is a load-bearing signal: `recursive` is a queryable node property + * and seeds the cycle detection in pass_complexity. */ +TEST(extract_elixir_guarded_clause_head_is_not_a_self_call) { + CBMFileResult *r = extract("defmodule Guarded do\n" /* 1 */ + " def f(x) when is_integer(x), do: x\n" /* 2 */ + " def f(_x), do: 0\n" /* 3 */ + "\n" /* 4 */ + " def solo(x) when is_binary(x), do: x\n" /* 5 */ + "end\n", /* 6 */ + CBM_LANG_ELIXIR, "t", "guard_call.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* No call to either name exists anywhere in this file. */ + ASSERT_EQ(0, count_calls_named(r, "f")); + ASSERT_EQ(0, count_calls_named(r, "solo")); + /* The guard predicate IS a real call and stays one. */ + ASSERT_EQ(1, count_calls_named(r, "is_integer")); + ASSERT_EQ(1, count_calls_named(r, "is_binary")); + + ASSERT_EQ(1, count_defs_named(r, "Function", "f")); + const CBMDefinition *f = elixir_first_def(r, "f"); + ASSERT_NOT_NULL(f); + ASSERT_EQ(2, (int)f->start_line); + ASSERT_EQ(3, (int)f->end_line); + ASSERT_FALSE(f->is_recursive); + + /* A single guarded clause was wrong even before the span widened: the + * phantom landed on its own one-line node. */ + const CBMDefinition *solo = elixir_first_def(r, "solo"); + ASSERT_NOT_NULL(solo); + ASSERT_FALSE(solo->is_recursive); + cbm_free_result(r); + PASS(); +} + +/* Every reference of any kind this file records for `name`, across all four + * extractors the unified walk runs over one subtree: a call site, a value + * usage, a read/write occurrence, a type reference. Suppressing a node in ONE + * of them does not remove a phantom reference, it moves it -- handle_calls + * declining a node leaves state->callee_expr unset, and handle_usages then + * reaches the bare identifier and mints a USAGE instead. USAGE and CALLS are + * separate rows in the edge table, so the relabelled phantom also escapes the + * UNIQUE(source,target,type) dedup that had been collapsing it onto a real + * call, and pass_importance counts an inbound reference that did not exist + * before. An assertion naming only r->calls cannot see any of that. */ +static int elixir_any_ref_count(CBMFileResult *r, const char *name) { + int count = count_calls_named(r, name); + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, name) == 0) { + count++; + } + } + for (int i = 0; i < r->rw.count; i++) { + if (r->rw.items[i].var_name && strcmp(r->rw.items[i].var_name, name) == 0) { + count++; + } + } + for (int i = 0; i < r->type_refs.count; i++) { + if (r->type_refs.items[i].type_name && strcmp(r->type_refs.items[i].type_name, name) == 0) { + count++; + } + } + return count; +} + +/* The head suppression has to REMOVE the phantom, not relabel it. What makes + * that hold for a def head is not the calls walk alone: is_elixir_def_binding in + * extract_usages.c already treats everything inside a def's first argument as + * a binding occurrence rather than a reference, so the identifier handle_calls + * declines is declined by handle_usages too. That agreement is the invariant + * under test, and it is why the counts below are over every extractor rather + * than over r->calls. + * + * `spec_subject` pins the boundary 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 @@ -13762,7 +14527,18 @@ 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_nested_module_clauses_do_not_fold); + RUN_TEST(extract_elixir_folded_clause_is_exported_is_the_or); + RUN_TEST(extract_elixir_guarded_clause_head_is_not_a_self_call); + RUN_TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind); + RUN_TEST(extract_elixir_real_recursion_survives_head_suppression); + RUN_TEST(extract_elixir_typespec_attribute_is_not_code); RUN_TEST(elixir_call_string_argument); + RUN_TEST(extract_elixir_guarded_def_head_scope); + RUN_TEST(extract_elixir_guarded_def_head_guard_usages); RUN_TEST(elixir_channels_pubsub_and_handle_in); RUN_TEST(haskell_function); RUN_TEST(haskell_infix_definition_name); diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index 742390ae6..faffd78e4 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -173,6 +173,13 @@ TEST(infrascan_service_pattern_match_rejects_ids_inside_words) { {"kafkaesque.story.tell", CBM_SVC_NONE}, /* "Axios" glued to a lowercase continuation */ {"geo.Axiosphere.map", CBM_SVC_NONE}, + /* Angular receiver rows end in a dot (#2768): a longer receiver that + * only starts with the same letters is not the HttpClient (NestJS + * route registration, AngularJS test double) */ + {"this.httpAdapter.get", CBM_SVC_NONE}, + {"this.httpServer.post", CBM_SVC_NONE}, + {"$httpBackend.expectGET", CBM_SVC_NONE}, + {"this.httpAdapter\n .get", CBM_SVC_NONE}, {NULL, CBM_SVC_NONE}, }; ASSERT_EQ(svc_case_mismatches(cases), 0); @@ -202,6 +209,18 @@ TEST(infrascan_service_pattern_match_keeps_real_library_qns) { {"Axios.post", CBM_SVC_HTTP}, {"AxiosInstance.get", CBM_SVC_HTTP}, {"curl_exec", CBM_SVC_HTTP}, + /* Angular HttpClient receivers (#2768); `_httpClient` is the common + * C# field spelling of the same client */ + {"this.http.get", CBM_SVC_HTTP}, + {"this.httpClient.post", CBM_SVC_HTTP}, + {"httpClient.put", CBM_SVC_HTTP}, + {"_httpClient.GetAsync", CBM_SVC_HTTP}, + {"$http.get", CBM_SVC_HTTP}, + /* a chain wrapped before the verb keeps the line break (LF / CRLF) */ + {"this.http\n .post", CBM_SVC_HTTP}, + {"this.http\r\n .delete", CBM_SVC_HTTP}, + {"this.httpClient\n .put", CBM_SVC_HTTP}, + {"$http\n .get", CBM_SVC_HTTP}, {"surf.get", CBM_SVC_HTTP}, {"hyper.Client.request", CBM_SVC_HTTP}, {"phin.promisified", CBM_SVC_HTTP}, diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 42dd6b5a6..b82fb3098 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -10227,6 +10227,57 @@ TEST(tool_delete_project_mutation_guard_blocks_then_releases) { PASS(); } +/* delete_project removes the ADR sidecar with the project, and a re-index of a + * same-named project afterwards starts with no ADR (round-2 review item 7c). */ +TEST(tool_delete_project_removes_adr_sidecar) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-adr-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "adr-delete-project"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *setup = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(setup); + ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/adr-delete-project"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_store(setup, project, "# Decision\nkeep it"), CBM_STORE_OK); + cbm_store_close(setup); + + char adr_path[CBM_SZ_1K]; + snprintf(adr_path, sizeof(adr_path), "%s.adr.db", db_path); + ASSERT_TRUE(cbm_file_exists(adr_path)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"adr-delete-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "deleted")); + free(resp); + + ASSERT_FALSE(cbm_file_exists(db_path)); + ASSERT_FALSE(cbm_file_exists(adr_path)); /* sidecar removed with the project */ + + /* A fresh store of the same name starts with no ADR. */ + cbm_store_t *fresh = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(fresh); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(fresh, project, &adr), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&adr); + cbm_store_close(fresh); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cbm_rmdir(cache); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + PASS(); +} + TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) { char root[CBM_SZ_1K]; (void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir()); @@ -26916,6 +26967,7 @@ SUITE(mcp_mutation_guard) { RUN_TEST(tool_ingest_traces_mutation_guard_missing_store_releases); RUN_TEST(tool_ingest_traces_mutation_guard_query_failure_releases); RUN_TEST(tool_delete_project_mutation_guard_blocks_then_releases); + RUN_TEST(tool_delete_project_removes_adr_sidecar); RUN_TEST(tool_index_repository_mutation_guard_blocks_before_local_worker); RUN_TEST(tool_manage_adr_mutation_guard_balances_success); RUN_TEST(tool_manage_adr_read_paths_skip_blocking_mutation_guard); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 8f921e62e..9157c4bcd 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1352,6 +1352,217 @@ TEST(pipeline_records_unresolved_injected_call_sites) { PASS(); } +/* An ADR written from a test hook BETWEEN the start of a reindex and its + * publication survives — the write lands in the ".adr.db" sidecar, which the + * graph-DB rebuild never touches (round-2 review item 7a, full route). */ +typedef struct { + char db_path[512]; + char project[256]; + const char *adr; +} adr_hook_ctx_t; + +static void write_adr_before_final_manifest(void *userdata) { + adr_hook_ctx_t *c = (adr_hook_ctx_t *)userdata; + cbm_store_t *s = cbm_store_open_path_existing(c->db_path); + if (!s) { + s = cbm_store_open_path(c->db_path); + } + if (s) { + (void)cbm_store_adr_store(s, c->project, c->adr); + cbm_store_close(s); + } +} + +TEST(pipeline_adr_written_during_reindex_survives) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_hook_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + adr_hook_ctx_t ctx = {.adr = "# Decision\nWritten mid-reindex."}; + snprintf(ctx.db_path, sizeof(ctx.db_path), "%s", db_path); + snprintf(ctx.project, sizeof(ctx.project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Force a full reindex, writing the ADR from the publication hook. */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_before_final_manifest_once(write_adr_before_final_manifest, &ctx); + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + /* Pin the route so this stays a full-rebuild test if routing changes. */ + ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_FORCED_FULL); + cbm_pipeline_free(p2); + cbm_pipeline_incremental_test_reset_faults(); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, ctx.project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, ctx.adr); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + +/* Migrate-before-rebuild fails closed: if the sidecar cannot be opened (here a + * DIRECTORY sits at ".adr.db"), the reindex must NOT delete the old + * generation — it returns CBM_PIPELINE_ABORT_PRESERVE_DB and the prior graph is + * left intact. Deterministic, no hook. */ +TEST(pipeline_adr_migrate_failure_preserves_generation) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_migfail_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + cbm_store_t *before = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(before); + int nodes_before = cbm_store_count_nodes(before, project); + cbm_store_close(before); + ASSERT_TRUE(nodes_before > 0); + + /* A directory at ".adr.db" makes the sidecar open (and thus the + * migrate-before-delete step) fail deterministically. */ + char adr_dir[600]; + snprintf(adr_dir, sizeof(adr_dir), "%s.adr.db", db_path); + ASSERT_EQ(cbm_mkdir(adr_dir), 0); + + /* Force a full reindex; migrate-before-delete must fail closed. */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + int rc = cbm_pipeline_run(p2); + cbm_pipeline_free(p2); + ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); + + /* Old generation intact: the graph DB was not deleted or rebuilt. */ + ASSERT_TRUE(cbm_file_exists(db_path)); + cbm_store_t *after = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(after); + ASSERT_EQ(cbm_store_count_nodes(after, project), nodes_before); + cbm_store_close(after); + + rm_rf(tmp); + PASS(); +} + +/* Upgrade order: a legacy ADR row (pre-sidecar, in the graph DB) is migrated + * BEFORE a forced-full reindex deletes the old generation, with NO ADR read + * first — the pipeline's migrate-before-rebuild step must move it (item 7b). */ +TEST(pipeline_adr_legacy_migrates_before_forced_reindex) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_upgrade_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Seed the ADR directly into the graph DB's project_summaries (the legacy + * layout), bypassing the sidecar entirely. */ + static const char adr_text[] = "# Decision\nLegacy row, migrated before rebuild."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + sqlite3_exec(raw, + "CREATE TABLE IF NOT EXISTS project_summaries (project TEXT PRIMARY KEY," + " summary TEXT NOT NULL, source_hash TEXT NOT NULL, created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL);", + NULL, NULL, NULL); + char *sql = sqlite3_mprintf( + "INSERT OR REPLACE INTO project_summaries VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z'," + " '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* No ADR read here. Force a full reindex (which deletes the old graph DB). */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_pipeline_free(p2); + + /* The legacy ADR was migrated to the sidecar before the delete, so it is + * still readable. */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + TEST(pipeline_structure_edges) { if (setup_test_repo() != 0) { FAIL("failed to create temp dir"); @@ -6447,6 +6658,60 @@ TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full) { PASS(); } +/* Companion to pipeline_adr_written_during_reindex_survives (the full route): + * an ADR written from the publication hook mid-reindex must also survive the + * closure-DELTA route (item 7a — "both the full and the delta route"). The + * delta route clones/patches/renames the graph DB but never touches the + * ".adr.db" sidecar, so the hook-written ADR is preserved. A body-only edit + * forces the closure-repair (delta) route, which we assert explicitly so this + * can't silently pass on a full rebuild. */ +TEST(pipeline_adr_written_during_delta_reindex_survives) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_delta_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + closure_probe_repo(tmp); + char db[512]; + snprintf(db, sizeof(db), "%s/closure.db", tmp); + + cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(baseline); + ASSERT_EQ(cbm_pipeline_run(baseline), 0); + adr_hook_ctx_t ctx = {.adr = "# Decision\nWritten mid-delta-reindex."}; + snprintf(ctx.db_path, sizeof(ctx.db_path), "%s", db); + snprintf(ctx.project, sizeof(ctx.project), "%s", cbm_pipeline_project_name(baseline)); + cbm_pipeline_free(baseline); + + /* Body-only edit (same exported surface) → closure-repair delta route. */ + write_temp_file(tmp, "lib.ts", + "export function closureProbeHelper(x: number): string {\n" + " const doubled = x + x;\n" + " return String(doubled);\n" + "}\n"); + + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_before_final_manifest_once(write_adr_before_final_manifest, &ctx); + cbm_pipeline_t *incr = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(incr); + ASSERT_EQ(cbm_pipeline_run(incr), 0); + cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route(); + cbm_pipeline_free(incr); + cbm_pipeline_incremental_test_reset_faults(); + /* Must be the DELTA route, not a full rebuild — otherwise this proves nothing. */ + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, ctx.project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, ctx.adr); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + th_rmtree(tmp); + PASS(); +} + /* Removing a definition (no additions) keeps the closure route AND must drop * the dependent's stale CALL_REFERENCE. This is the assertion legacy partial * could never pass: its QN-keyed re-link resurrected yesterday's edge. */ @@ -7478,7 +7743,6 @@ TEST(pipeline_publication_never_uses_a_predictable_staging_path) { .cancelled = NULL, .manifest = NULL, .manifest_count = 0, - .adr_content = NULL, .coverage = NULL, .coverage_count = 0, }; @@ -9119,79 +9383,215 @@ TEST(pipeline_incremental_successful_publication_preserves_adr) { PASS(); } -/* A forced-full rebuild must never erase an ADR merely because the old - * generation could not be read completely. The capture is part of the - * publication transaction: failure preserves both graph and ADR. */ -TEST(pipeline_full_adr_capture_failure_preserves_previous_generation) { +/* An ADR written in the pre-sidecar layout (a row in the graph DB's + * project_summaries) is migrated into the ".adr.db" sidecar the first time + * it is read, and then survives a full reindex — so upgrading needs no reindex + * and loses no decision record. */ +TEST(pipeline_adr_migrates_from_legacy_graph_row) { char tmp[256]; - snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_adr_capture_XXXXXX"); - ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); - write_temp_file(tmp, "generation.py", "def BeforeAdrCapture():\n return 1\n"); + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_migrate_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } char db_path[512]; - snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); - cbm_pipeline_incremental_test_reset_faults(); - cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(baseline); - ASSERT_EQ(cbm_pipeline_run(baseline), 0); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); char project[256]; - snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline)); - cbm_pipeline_free(baseline); + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); - static const char adr_text[] = "# Decision\nADR capture is fail-closed."; - cbm_store_t *adr_store = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(adr_store); - ASSERT_EQ(cbm_store_adr_store(adr_store, project, adr_text), CBM_STORE_OK); - cbm_store_close(adr_store); + /* Seed the ADR directly into the graph DB's project_summaries — the layout + * before the sidecar existed — bypassing cbm_store_adr_store (which now + * writes the sidecar). */ + static const char adr_text[] = "# Decision\nLegacy row migrates to the sidecar."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); - write_temp_file(tmp, "generation.py", "def AfterAdrCapture():\n return 2\n"); - cbm_pipeline_incremental_test_fail_adr_capture_once(); - cbm_pipeline_t *faulted = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(faulted); - int faulted_rc = cbm_pipeline_run(faulted); - cbm_pipeline_free(faulted); + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + ASSERT_TRUE(!cbm_file_exists(adr_db)); /* no sidecar yet */ - int faulted_before = -1; - int faulted_after = -1; - observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture", - &faulted_before, &faulted_after); - cbm_store_t *preserved = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(preserved); - cbm_adr_t preserved_adr = {0}; - int preserved_adr_rc = cbm_store_adr_get(preserved, project, &preserved_adr); - bool preserved_adr_matches = preserved_adr_rc == CBM_STORE_OK && preserved_adr.content && - strcmp(preserved_adr.content, adr_text) == 0; - cbm_store_adr_free(&preserved_adr); - cbm_store_close(preserved); + /* A READ returns the legacy row from the graph DB and must NOT create the + * sidecar (a query-only cache must stay unwritten). */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + ASSERT_TRUE(!cbm_file_exists(adr_db)); /* read did not create the sidecar */ - cbm_pipeline_incremental_test_reset_faults(); - cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(retry); - int retry_rc = cbm_pipeline_run(retry); - cbm_pipeline_free(retry); - int retry_before = -1; - int retry_after = -1; - observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture", &retry_before, - &retry_after); - cbm_store_t *published = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(published); - cbm_adr_t published_adr = {0}; - int published_adr_rc = cbm_store_adr_get(published, project, &published_adr); - bool published_adr_matches = published_adr_rc == CBM_STORE_OK && published_adr.content && - strcmp(published_adr.content, adr_text) == 0; - cbm_store_adr_free(&published_adr); - cbm_store_close(published); - cbm_pipeline_incremental_test_reset_faults(); - th_rmtree(tmp); + /* Force a full reindex; routing migrates the legacy row into the sidecar + * before the old generation is deleted, so the ADR survives. */ + for (int i = 0; i < 4; i++) { + char extra[512]; + snprintf(extra, sizeof(extra), "%s/extra%d.py", tmp, i); + f = fopen(extra, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_pipeline_free(p2); - ASSERT_EQ(faulted_rc, CBM_PIPELINE_ABORT_PRESERVE_DB); - ASSERT_EQ(faulted_before, 1); - ASSERT_EQ(faulted_after, 0); - ASSERT_TRUE(preserved_adr_matches); - ASSERT_EQ(retry_rc, 0); - ASSERT_EQ(retry_before, 0); - ASSERT_EQ(retry_after, 1); - ASSERT_TRUE(published_adr_matches); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_adr_t adr2 = {0}; + ASSERT_EQ(cbm_store_adr_get(s2, project, &adr2), CBM_STORE_OK); + ASSERT_NOT_NULL(adr2.content); + ASSERT_STR_EQ(adr2.content, adr_text); + cbm_store_adr_free(&adr2); + cbm_store_close(s2); + + rm_rf(tmp); + PASS(); +} + +/* A sidecar that exists but was never marked `migrated` is half-created (a crash + * or a killed process between CREATE and the migration COMMIT). A read must fall + * back to the legacy graph row so a partial sidecar never hides an ADR. */ +TEST(pipeline_adr_half_created_sidecar_falls_back_to_legacy) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_halfcreated_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Legacy ADR still only in the graph DB (pre-sidecar layout). */ + static const char adr_text[] = "# Decision\nHalf-created sidecar must not hide me."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* Fabricate a HALF-CREATED sidecar: schema present, NO `migrated` marker. */ + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + sqlite3 *side = NULL; + ASSERT_EQ(sqlite3_open(adr_db, &side), SQLITE_OK); + ASSERT_EQ(sqlite3_exec(side, + "CREATE TABLE project_summaries (project TEXT PRIMARY KEY, summary TEXT " + "NOT NULL, source_hash TEXT NOT NULL, created_at TEXT NOT NULL, " + "updated_at TEXT NOT NULL);" + "CREATE TABLE adr_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);", + NULL, NULL, NULL), + SQLITE_OK); + sqlite3_close(side); + ASSERT_TRUE(cbm_file_exists(adr_db)); /* sidecar exists but is unmarked */ + + /* The read must fall back to the legacy graph row, not report NOT_FOUND. */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + +/* ADRs live in the ".adr.db" sidecar, separate from the graph DB, and + * removing that sidecar (what delete_project does) removes the ADR store. */ +TEST(pipeline_adr_sidecar_is_separate_and_removable) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_sidecar_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + static const char adr_text[] = "# Decision\nADRs live in the sidecar."; + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_adr_store(s, project, adr_text), CBM_STORE_OK); + cbm_store_close(s); + + /* The ADR is in the sidecar, NOT in the graph DB's project_summaries. */ + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + ASSERT_TRUE(cbm_file_exists(adr_db)); + + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + sqlite3_stmt *st = NULL; + ASSERT_EQ(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM project_summaries;", -1, &st, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(st), SQLITE_ROW); + int graph_rows = sqlite3_column_int(st, 0); + sqlite3_finalize(st); + sqlite3_close(raw); + ASSERT_EQ(graph_rows, 0); + + /* Removing the sidecar (as delete_project does) removes the ADR store. */ + ASSERT_EQ(cbm_unlink(adr_db), 0); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_adr_t adr = {0}; + int rc = cbm_store_adr_get(s2, project, &adr); + cbm_store_adr_free(&adr); + cbm_store_close(s2); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + rm_rf(tmp); PASS(); } @@ -11335,6 +11735,416 @@ TEST(pipeline_go_init_lsp_edges_per_file_parallel) { PASS(); } +/* Fixture for the bare-`init` probes (sequential and parallel twins), the + * shapes the kubernetes census found after #1915: a local closure `init := + * func(ctx)` that is called, written and passed by value (the e2e storage + * suites), a parameter named init (`func sum(init func() ref.Val)`), and a + * `package init` directory. The decoys are a package init in the caller's + * own package (what same_module bound before #1915) and an unrelated + * Watcher.init method (what the name fallback binds after it). Watcher.Start + * and Boot call that method through a selector, the control that must keep + * resolving. pad_files > 0 adds filler Go files past MIN_FILES_FOR_PARALLEL. */ +static void write_go_bare_init_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fxbareinit\n\ngo 1.22\n"); + write_temp_file(tmp, "suites/suite.go", + "package suites\n" + "\n" + "import \"context\"\n" + "\n" + "var registered []string\n" + "\n" + "func init() { registered = append(registered, \"volumes\") }\n" + "\n" + "func wrap(fn func(context.Context)) { _ = fn }\n" + "\n" + "func DefineTests(ctx context.Context) {\n" + "\tinit := func(ctx context.Context) {\n" + "\t\t_ = ctx\n" + "\t}\n" + "\tinit(ctx)\n" + "\twrap(init)\n" + "}\n" + "\n" + "func Sum(init func() int) int {\n" + "\treturn init()\n" + "}\n"); + write_temp_file(tmp, "watcher/watcher.go", + "package watcher\n" + "\n" + "type Watcher struct{ ready bool }\n" + "\n" + "func NewWatcher() *Watcher { return &Watcher{} }\n" + "\n" + "func (w *Watcher) init() { w.ready = true }\n" + "\n" + "func (w *Watcher) Start() {\n" + "\tw.init()\n" + "}\n" + "\n" + "func Boot() {\n" + "\tw := NewWatcher()\n" + "\tw.init()\n" + "}\n"); + write_temp_file(tmp, "boot/init/init.go", + "// Package init installs defaults for its importers.\n" + "package init\n" + "\n" + "func init() { setup() }\n" + "\n" + "func setup() {}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +static bool bare_init_qn_ends_with(const char *qn, const char *suffix) { + size_t ql = strlen(qn); + size_t sl = strlen(suffix); + return ql >= sl && strcmp(qn + ql - sl, suffix) == 0; +} + +/* Resolver edges into any node named `init` whose source is not one of the + * two selector controls. Every such edge in the fixture binds a bare `init`, + * which in Go is always a local name. */ +static int go_bare_init_false_edges(cbm_store_t *s, const char *project) { + static const char *const types[] = {"CALLS", "USAGE", "WRITES", "READS", "CALL_REFERENCE"}; + int hits = 0; + for (size_t t = 0; t < sizeof(types) / sizeof(types[0]); t++) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, types[t], &edges, &edge_count); + for (int i = 0; i < edge_count; i++) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK && + cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK && + src.qualified_name && tgt.name && strcmp(tgt.name, "init") == 0 && + !bare_init_qn_ends_with(src.qualified_name, ".watcher.Watcher.Start") && + !bare_init_qn_ends_with(src.qualified_name, ".watcher.Boot")) { + printf(" bare init bound: %s %s -> %s\n", types[t], src.qualified_name, + tgt.qualified_name ? tgt.qualified_name : "?"); + hits++; + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + } + return hits; +} + +static int assert_go_bare_init_edges(cbm_store_t *s, const char *project) { + /* The decoys exist, so a zero below is a refused binding and not an + * absent target: two package inits and the Watcher method. */ + cbm_node_t *inits = NULL; + int ic = 0; + cbm_store_find_nodes_by_name(s, project, "init", &inits, &ic); + int functions = 0; + int methods = 0; + for (int i = 0; i < ic; i++) { + functions += inits[i].label && strcmp(inits[i].label, "Function") == 0; + methods += inits[i].label && strcmp(inits[i].label, "Method") == 0; + } + cbm_store_free_nodes(inits, ic); + ASSERT_EQ(functions, 2); + ASSERT_EQ(methods, 1); + /* NEGATIVE: no bare `init` (closure call, closure write, closure passed + * by value, parameter call, `package init`) binds a package init or the + * unrelated method. */ + ASSERT_EQ(go_bare_init_false_edges(s, project), 0); + /* CONTROL: a selector call `w.init()` is not bare and still resolves to + * the method, from a method body and from a free function. */ + ASSERT_GTE( + go_calls_between(s, project, ".watcher.Watcher.Start", ".watcher.Watcher.init", NULL), 1); + ASSERT_GTE(go_calls_between(s, project, ".watcher.Boot", ".watcher.Watcher.init", NULL), 1); + return 0; +} + +/* R45 (follow-up to #1915): a bare Go `init` never binds a package init or, + * by name alone, an init method (cbm_go_suppress_bare_init_bind). */ +TEST(pipeline_go_bare_init_binds_nothing) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_bare_init_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_init_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_bare_init.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, 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 *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(assert_go_bare_init_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Parallel twin: >= MIN_FILES_FOR_PARALLEL (50) files plus CBM_WORKERS=4 + * take the parallel resolver, which carries its own copy of each guard. */ +TEST(pipeline_go_bare_init_binds_nothing_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_bare_init_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_init_fixture(tmp, 52); + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_bare_init_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, 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 *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(assert_go_bare_init_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_go_parallel_field_hint_requires_owner_segment) { + /* #1927: try_field_type_hint (parallel resolver only) matched the hinted + * type name as a raw SUBSTRING of the candidate QN. A single-letter + * receiver — `f.Close()` on an *os.File — hints "F", and "F" is a + * substring of "…FileStore.Close", so a stdlib call was rebound to an + * arbitrary project method at PP_FIELD_HINT_CONF and presented as + * field_type_hint (the one weak strategy the Go selector guard trusts). + * The hint must fire only when the candidate's OWNING segment — the + * dot-segment immediately before the method — EQUALS the hinted type; + * otherwise the resolution stays a weak short-name match and the Go + * guard (#1906) drops it. >= 50 files forces pass_parallel.c, the only + * path that runs try_field_type_hint(). */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_fth_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "go.mod", "module example.com/fxhint\n\ngo 1.22\n"); + /* Two project methods named Close → the registry resolves f.Close() as an + * ambiguous short-name match (candidate_count 2), the shape the hint + * upgrades. FileStore's QN carries the capital F the buggy substring + * latches onto; Conn is the second candidate. */ + write_temp_file(tmp, "filestore/filestore.go", + "package filestore\n" + "\n" + "type FileStore struct{ open bool }\n" + "\n" + "func (fs *FileStore) Close() {\n" + "\tfs.open = false\n" + "}\n"); + write_temp_file(tmp, "conn/conn.go", + "package conn\n" + "\n" + "type Conn struct{ live bool }\n" + "\n" + "func (cn *Conn) Close() {\n" + "\tcn.live = false\n" + "}\n"); + /* Two project methods named Find → same ambiguity, but here the receiver + * variable IS named after its type (`finder`), the case the hint exists + * for: the owning segment of Finder.Find equals the hinted "Finder". */ + write_temp_file(tmp, "finder/finder.go", + "package finder\n" + "\n" + "type Finder struct{ n int }\n" + "\n" + "func NewFinder() *Finder { return &Finder{n: 1} }\n" + "\n" + "func (fi *Finder) Find(id int) int {\n" + "\treturn fi.n + id\n" + "}\n"); + write_temp_file(tmp, "locator/locator.go", + "package locator\n" + "\n" + "type Locator struct{ n int }\n" + "\n" + "func (lo *Locator) Find(id int) int {\n" + "\treturn lo.n - id\n" + "}\n"); + write_temp_file(tmp, "app/app.go", + "package app\n" + "\n" + "import (\n" + "\t\"os\"\n" + "\n" + "\txf \"example.com/fxhint/finder\"\n" + ")\n" + "\n" + "func Lookup(path string) int64 {\n" + "\tf, err := os.Open(path)\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\tdefer f.Close()\n" + "\tst, err := f.Stat()\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\treturn st.Size()\n" + "}\n" + "\n" + "func UseFinder(id int) int {\n" + "\tfinder := xf.NewFinder()\n" + "\treturn finder.Find(id)\n" + "}\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_fth.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, 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 *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* (1) Reproduce-first: RED before the fix. The substring hint rebinds the + * stdlib f.Close() to FileStore.Close ("F" ⊂ QN) at high confidence and + * the Go guard keeps it. With segment equality the upgrade is refused, + * the match stays suffix_match, and the Go guard drops it. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "Lookup", "Close")); + /* (2) The intended hint case survives: `finder` names its type, so the + * owning segment of Finder.Find equals the hint. */ + ASSERT_TRUE(cross_file_call_exists(s, project, "UseFinder", "Find")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +static int count_calls_with(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, const char *fragment); + +TEST(pipeline_csharp_parallel_field_hint_requires_owner_segment) { + /* #1927, non-Go twin. Go no longer runs try_field_type_hint (e554a2ab), + * but every other language on the parallel path still does, and C# is the + * hint's intended consumer (it strips the _ / m_ field prefixes). C# + * method names are PascalCase, so a one-letter receiver hints a letter + * that every candidate QN already contains in the METHOD name: `w.Write()` + * hints "W", and a raw substring test accepts "...FileStore.Write" and + * "...Conn.Write" alike, whichever the registry lists first. The hint must + * bind only a candidate whose OWNING segment equals the hinted type. + * >= 50 files forces pass_parallel.c, the only path with the hint. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_cs_fth_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* Two project methods named Write: an untyped receiver resolves as an + * ambiguous short-name match (candidate_count 2), the shape the hint + * upgrades. */ + write_temp_file(tmp, "Storage/FileStore.cs", + "namespace Storage {\n" + " public class FileStore {\n" + " private int written;\n" + " public void Write(int v) { written += v; }\n" + " public void Put(int v) { written = v; }\n" + " }\n" + "}\n"); + write_temp_file(tmp, "Net/Conn.cs", + "namespace Net {\n" + " public class Conn {\n" + " private int sent;\n" + " public void Write(int v) { sent += v; }\n" + " }\n" + "}\n"); + /* Two project methods named Put, one owned by Store: the receiver `store` + * names its type, the case the hint exists for. FileStore's QN also + * contains "Store", so only segment equality picks Store.Put whatever + * the candidate order. */ + write_temp_file(tmp, "Repo/Store.cs", + "namespace Repo {\n" + " public class Store {\n" + " private int held;\n" + " public void Put(int v) { held = v; }\n" + " }\n" + "}\n"); + write_temp_file(tmp, "App/Worker.cs", + "namespace App {\n" + " public class Worker {\n" + " public void Log(System.IO.StreamWriter w) {\n" + " w.Write(1);\n" + " }\n" + " public void Keep() {\n" + " var store = Services.Resolve();\n" + " store.Put(2);\n" + " }\n" + " }\n" + "}\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[192]; + snprintf(name, sizeof(name), "Pad/Filler%d.cs", i); + snprintf(body, sizeof(body), + "namespace Pad {\n public class Filler%d {\n" + " public int Get%d() { return %d; }\n }\n}\n", + i, i, i); + write_temp_file(tmp, name, body); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/cs_fth.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, 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 *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* (1) Reproduce-first: RED before the fix. "W" is a substring of every + * Write candidate, so the old hint upgraded the StreamWriter call to an + * arbitrary project Write at PP_FIELD_HINT_CONF. No owner segment is "W", + * so the fixed hint must not fire at all. */ + ASSERT_FALSE(cross_file_call_has_strategy(s, project, "Log", "Write", "field_type_hint")); + /* (2) The intended hint case binds the owner it names, never FileStore. */ + ASSERT_EQ(count_calls_with(s, project, ".Keep", ".Store.Put", "field_type_hint"), 1); + ASSERT_EQ(count_calls_with(s, project, ".Keep", ".FileStore.Put", NULL), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Fixture for the #1928 cross-language reference-guard probes (sequential and * parallel twins). pad_files > 0 adds filler files to push the index over the * parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per @@ -15642,6 +16452,203 @@ TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential) { return typed_http_no_url_twin_case(0); } +/* Issue #2768: Angular HttpClient calls (this.http.get, this.httpClient.post, + * this.http.delete) emit HTTP_CALLS edges typed with the method verb, and are + * never misclassified as route registrations. */ +static int angular_http_client_typed_case(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_angular_http_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/item.service.ts", + "import { Injectable } from '@angular/core';\n" + "import { HttpClient } from '@angular/common/http';\n" + "\n" + "@Injectable({ providedIn: 'root' })\n" + "export class ItemService {\n" + " constructor(private http: HttpClient) {}\n" + "\n" + " getItems() {\n" + " return this.http.get('/items/list');\n" + " }\n" + "\n" + " getItem(id: string) {\n" + " return this.http.get(`/items/${id}`);\n" + " }\n" + "\n" + " createItem(item: any) {\n" + " return this.http.post('/items', item);\n" + " }\n" + "\n" + " deleteItem(id: string) {\n" + " return this.http.delete(`/items/${id}`);\n" + " }\n" + "\n" + " updateItem(id: string, item: any) {\n" + " return this.http\n" + " .put(`/items/${id}`, item);\n" + " }\n" + "}\n"); + + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/angular_http.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, 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 *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* Assert exact HTTP_CALLS with proper methods */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__GET__/items/list"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__GET__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__POST__/items"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__DELETE__/items/{}"), 1); + /* A chain wrapped before the verb (prettier style) is the same client call */ + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__PUT__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__ANY__"), 0); + + /* Assert no ANY twins */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__ANY__"), 0); + + /* Angular service must NOT emit HANDLES edges (not a server route registration) */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_angular_http_client_typed_parallel) { + return angular_http_client_typed_case(52); +} + +TEST(pipeline_angular_http_client_typed_sequential) { + return angular_http_client_typed_case(0); +} + +/* #2768: the arg-URL heuristic (detect_url_in_args, via=arg_url) covers calls + * no service pattern knows, and it now types the edge with the verb the callee + * names instead of minting a method-less __route__ANY__ route. Two wrapper + * shapes reach it on the parallel resolver: + * - a project wrapper method whose verb is not a route-registration suffix + * (`this.api.head('/items/health')`, `.options(...)`), resolved to the + * project method, so the URL may be the first argument; + * - a route-verb wrapper (`.get`) whose URL is not route shaped for the + * registration reader: here a keyword it does not read as a path + * (`self.session.get(resource="/items/list")`). + * Parallel only: the sequential resolver has no arg-URL heuristic (#2848). */ +TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_arg_url_verb_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "src/items.api.ts", + "export class ItemsApi {\n" + " head(path: string): Promise {\n" + " return fetch(path, { method: 'HEAD' });\n" + " }\n" + " options(path: string): Promise {\n" + " return fetch(path, { method: 'OPTIONS' });\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/item.service.ts", + "import { ItemsApi } from './items.api';\n" + "export class ItemService {\n" + " constructor(private readonly api: ItemsApi) {}\n" + " pingItems() {\n" + " return this.api.head('/items/health');\n" + " }\n" + " probeItems() {\n" + " return this.api.options('/items/list');\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/reader.py", + "class ItemsSession:\n" + " def get(self, *args, **kwargs):\n" + " return args\n" + "\n" + "class Reader:\n" + " def __init__(self):\n" + " self.session = ItemsSession()\n" + "\n" + " def read_items(self):\n" + " return self.session.get(resource=\"/items/list\")\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/arg_url_verb.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, 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 *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + static const struct { + const char *caller; + const char *typed_route; + } wrapped[] = {{"pingItems", "__route__HEAD__/items/health"}, + {"probeItems", "__route__OPTIONS__/items/list"}, + {"read_items", "__route__GET__/items/list"}}; + for (size_t i = 0; i < sizeof(wrapped) / sizeof(wrapped[0]); i++) { + /* The edge carries the callee's verb ... */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, wrapped[i].typed_route), 1); + /* ... and is the call site's only HTTP_CALLS edge: no ANY twin. */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__"), 1); + } + /* Not a route registration */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* #706 / #1147 end to end: a URL composed from module constants reaches the * HTTP classifier, so the client call forms an HTTP_CALLS edge. Python * `API_URL = API_BASE_URL + "/orders/create"` (env-derived base, literal path @@ -30090,9 +31097,13 @@ SUITE(pipeline) { RUN_TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge); RUN_TEST(pipeline_go_multi_return_receiver_is_typed); RUN_TEST(pipeline_go_multi_return_receiver_is_typed_parallel); + RUN_TEST(pipeline_go_parallel_field_hint_requires_owner_segment); + RUN_TEST(pipeline_csharp_parallel_field_hint_requires_owner_segment); RUN_TEST(pipeline_go_multi_init_nodes_survive); RUN_TEST(pipeline_go_init_lsp_edges_per_file); RUN_TEST(pipeline_go_init_lsp_edges_per_file_parallel); + RUN_TEST(pipeline_go_bare_init_binds_nothing); + RUN_TEST(pipeline_go_bare_init_binds_nothing_parallel); RUN_TEST(pipeline_go_crossfile_type_alias_method); RUN_TEST(pipeline_go_crossfile_type_alias_method_parallel); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); @@ -30144,6 +31155,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential); + RUN_TEST(pipeline_angular_http_client_typed_parallel); + RUN_TEST(pipeline_angular_http_client_typed_sequential); + RUN_TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768); RUN_TEST(pipeline_url_constant_folding_http_calls_issue706_1147); RUN_TEST(pipeline_folded_topics_preserve_identity_sequential_parallel); RUN_TEST(pipeline_relative_folded_wrapper_url_candidates); @@ -30547,7 +31561,13 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_full_persist_failure_after_stage_dump_preserves_previous_generation); RUN_TEST(pipeline_incremental_persist_failure_preserves_previous_generation_and_retries); RUN_TEST(pipeline_incremental_successful_publication_preserves_adr); - RUN_TEST(pipeline_full_adr_capture_failure_preserves_previous_generation); + RUN_TEST(pipeline_adr_migrates_from_legacy_graph_row); + RUN_TEST(pipeline_adr_sidecar_is_separate_and_removable); + RUN_TEST(pipeline_adr_half_created_sidecar_falls_back_to_legacy); + RUN_TEST(pipeline_adr_written_during_reindex_survives); + RUN_TEST(pipeline_adr_written_during_delta_reindex_survives); + RUN_TEST(pipeline_adr_migrate_failure_preserves_generation); + RUN_TEST(pipeline_adr_legacy_migrates_before_forced_reindex); RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root); #ifndef _WIN32 RUN_TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory); diff --git a/tests/test_registry.c b/tests/test_registry.c index 9e99376ba..b711e32ed 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1260,6 +1260,45 @@ TEST(go_bare_ref_never_binds_field) { PASS(); } +TEST(go_bare_init_never_binds_init_namesake) { + /* Go cannot refer to a package init function, and a method or field + * named init needs a selector, so a bare `init` (a closure, a parameter, + * a `package init` clause) is always a local name. */ + /* Every Go Function named init is a package init: refused for every + * registry strategy, same_module included (the pre-#1915 binding). */ + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", NULL, "Function")); + /* A Method or Field namesake found by the name alone is refused. */ + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Method")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "unique_name", "Method")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Field")); + /* ... but not one that an import- or module-aware strategy produced. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Method")); + /* lsp_* answers are kept: the sequential resolver emits them before its + * registry guards run, so refusing one here would split the resolvers. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Method")); + /* The member half of a selector (`w.init()`, `e.init`) is not bare. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Field")); + /* Other names and other target kinds are untouched. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "initAll", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "Init", "suffix_match", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Variable")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Module")); + /* Go only: elsewhere a bare `init()` is an ordinary call (a Java or C++ + * method body calls its own init, a C or Python init is a function). */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "same_module", "Function")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, NULL, "suffix_match", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", NULL)); + PASS(); +} + TEST(c_bare_ref_never_binds_field) { /* #2666: a C struct member is named only as the member half of `a.b`, * `a->b` or a designator `.b` (field_identifier → is_member_access). A @@ -2656,6 +2695,7 @@ SUITE(registry) { RUN_TEST(registry_tie_prefers_csharp_implementation_over_ref_stub); RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); + RUN_TEST(go_bare_init_never_binds_init_namesake); RUN_TEST(c_bare_ref_never_binds_field); RUN_TEST(call_onto_field_follows_language_and_shape); RUN_TEST(c_bare_call_does_not_weigh_field_namesakes); diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 92af0994e..64a118288 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -620,6 +620,22 @@ TEST(adr_store_and_retrieve) { PASS(); } +TEST(adr_restore_from_graph_if_absent_memory_is_noop) { + /* A :memory: store keeps ADRs in the graph DB, so restore-if-absent is a + * well-formed no-op (returns OK) and leaves an existing ADR untouched. */ + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_store(s, "test", "v1"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_restore_from_graph_if_absent(s), CBM_STORE_OK); + cbm_adr_t adr; + ASSERT_EQ(cbm_store_adr_get(s, "test", &adr), CBM_STORE_OK); + ASSERT_STR_EQ(adr.content, "v1"); + cbm_store_adr_free(&adr); + cbm_store_close(s); + PASS(); +} + TEST(adr_upsert) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -2033,6 +2049,7 @@ SUITE(store_arch) { /* ADR */ RUN_TEST(adr_store_and_retrieve); RUN_TEST(adr_upsert); + RUN_TEST(adr_restore_from_graph_if_absent_memory_is_noop); RUN_TEST(adr_delete); RUN_TEST(adr_delete_not_found); RUN_TEST(doc_candidates_replace_and_get);