mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge origin/main into fix/1084-background-index-cpu
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
commit
2d5284e875
36 files changed
+3839
-256
No files matched your search
@@ -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. |
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"), ...)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, "{}");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user