Merge origin/main into fix/1084-background-index-cpu

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-11-01 00:06:53 +01:00
commit 2d5284e875
36 files changed
+3839 -256

No files matched your search

+1 -1
View File
@@ -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 (`<db>.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. |
+4 -4
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+78 -2
View File
@@ -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);
}
+148 -8
View File
@@ -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);
+113 -2
View File
@@ -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(<attr>, 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;
+5
View File
@@ -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;
+87
View File
@@ -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"), ...)
+20
View File
@@ -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,
+19
View File
@@ -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},
+2 -2
View File
@@ -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
+47 -5
View File
@@ -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 ("<db>.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 "<db>.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++;
}
}
+8
View File
@@ -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 ("<db>.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);
}
+27 -5
View File
@@ -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
* "<db>.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 "<db>.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 ("<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);
+91 -8
View File
@@ -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 "<db>.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)));
+8
View File
@@ -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,
+10
View File
@@ -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;
}
+68 -7
View File
@@ -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, "{}");
}
+13
View File
@@ -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, "{}");
+50 -55
View File
@@ -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 "<db>.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
* "<final>.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 "<db>.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;
+10
View File
@@ -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
+6 -46
View File
@@ -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 "<db>.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);
-2
View File
@@ -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);
+40
View File
@@ -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
+363 -42
View File
@@ -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 ("<db_path>.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 ("<graph_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 ("<db_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;
+11
View File
@@ -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);
+241
View File
@@ -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 "<db>.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 "<db>.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
* "<db>.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);
+147
View File
@@ -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);
+40
View File
@@ -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);
+191
View File
@@ -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);
+776
View File
@@ -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(<attr>, 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);
+19
View File
@@ -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},
+52
View File
@@ -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);
+1086 -66
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -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);
+17
View File
@@ -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);