Merge origin/main into feat/codex-skill-location-compat

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-11-01 03:03:02 +01:00
commit 12920dae15
51 files changed
+5459 -335

No files matched your search

+20
View File
@@ -696,6 +696,26 @@ MIMALLOC_CFLAGS_TEST = -std=c11 -g -O1 -w \
-Ivendored/mimalloc/include \
-Ivendored/mimalloc/src \
-DMI_OVERRIDE=0 $(MIMALLOC_WIN_DEFINE)
# MI_WIN_INIT_USE_FLS (MinGW TEST build only) is an EXPERIMENT (R32) for the
# Windows test leg's nondeterministic crash: 1-3 suites die in their first
# test with 0xC0000005 and no sanitizer report, seen only on trees with
# mimalloc 3.5.3. 3.5.3 is the first version whose loader hooks are live on
# MinGW: TLS callbacks in .CRT$XLB/.CRT$XLY run _mi_auto_process_init under the
# loader lock and _mi_thread_done on every thread detach. With FLS,
# src/prim/windows/prim.c compiles none of those hooks; process init runs once,
# from the constructor in src/prim/prim.c (no double init), and thread-done
# goes through a fiber-local destructor. MI_DEBUG=1 is what FLS needs to run at
# all: the first thread init (src/init.c) associates the main theap before the
# FLS key exists, which trips an internal assertion in prim.c and aborts every
# MI_DEBUG=2 process at startup. Level 1 keeps the fill patterns, padding and
# double-free checks and drops only the internal assertions. The production
# allocator (MIMALLOC_CFLAGS) is unchanged. Drop or promote this when the
# experiment ends.
MIMALLOC_WIN_TEST_DEFINE :=
ifeq ($(IS_MINGW),yes)
MIMALLOC_WIN_TEST_DEFINE := -DMI_WIN_INIT_USE_FLS=1 -DMI_DEBUG=1
MIMALLOC_CFLAGS_TEST += $(MIMALLOC_WIN_TEST_DEFINE)
endif
# sqlite3 (vendored amalgamation — compiled ourselves for ASan instrumentation)
# SQLITE_ENABLE_FTS5: enables the FTS5 full-text search extension used by the
+1 -1
View File
@@ -911,7 +911,7 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a
|------|-------------|
| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected (under `daemon start`, also after it disconnects), subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
| `list_projects` | List indexed projects with paging; use `detail: "stats"` for node/edge counts and database sizes. |
| `delete_project` | Remove a project and all its graph data. |
| `delete_project` | Remove a project and all its graph data, including its ADR sidecar (`<db>.adr.db`). |
| `index_status` | Check indexing status of a project. |
| `check_index_coverage` | Check whether exact paths or a scope are indexed and fresh. A clean result means no recorded gap, not proof of completeness. |
+4 -4
View File
@@ -32,7 +32,7 @@
"playwright": "1.62.1",
"three-stdlib": "2.36.1",
"typescript": "5.9.3",
"vite": "6.4.4",
"vite": "6.4.5",
"vitest": "4.1.12"
}
},
@@ -5277,9 +5277,9 @@
}
},
"node_modules/vite": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
"integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
"version": "6.4.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz",
"integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -62,7 +62,7 @@
"playwright": "1.62.1",
"three-stdlib": "2.36.1",
"typescript": "5.9.3",
"vite": "6.4.4",
"vite": "6.4.5",
"vitest": "4.1.12"
},
"overrides": {
+3 -2
View File
@@ -289,8 +289,9 @@ typedef struct {
* qualified_name (base QN = the first qn_sig_off bytes); 0 = no suffix.
* Always 0 until a language enables its callable_identity mode. */
uint32_t qn_sig_off;
/* C# only: declared namespace of a TOP-LEVEL type (NULL for nested types,
* the global namespace and every other language). Per type, because the
/* C# only: declared namespace of a TOP-LEVEL type, "" for the global
* namespace (proven only by a clean parse), NULL for nested types, an
* unknown namespace and every other language. Per type, because the
* file-level namespace_name records only a file's first namespace. */
const char *decl_namespace;
CBMTestDefinitionRole test_role; /* NONE preserves legacy helper/test-file semantics */
+78 -2
View File
@@ -718,6 +718,66 @@ static bool elixir_call_head_in(TSNode call, const char *source, const char *con
call_node_text_in(ts_node_child(call, 0), source, heads);
}
/* A definition's head declares a name; it is never a call to it. The head under
* a guard is not the def's first argument -- `def f(x) when g` parses its whole
* head as a `when` binary_operator -- so the plain node comparison below stopped
* recognising it, and the inner `f(x)` was recorded as a call to `f`. Under the
* widened span that phantom lands inside the function's own node, and cbm.c
* decides self-recursion by line containment, so an ordinary two-clause guarded
* function reported itself recursive. `recursive` is a queryable node property
* and seeds the cycle detection in pass_complexity, so that is a load-bearing
* signal, not a cosmetic one. The suppression therefore travels with the fold
* rather than following it.
*
* The head comes from cbm_elixir_def_head_is (helpers.c) rather than a private
* peel here, because three files read this same node and all three have to
* agree on which node is the head, or a node one of them is treating as a
* definition name is recorded by another as a reference to that very name:
* - extract_defs.c, extract_elixir_func_def, unwraps it to NAME the
* definition. It and this file disagreeing mints the phantom above.
* - extract_usages.c, is_elixir_def_binding, treats an identifier inside that
* head as a binding occurrence rather than a reference, which is what keeps
* a head suppressed here from re-emerging as a USAGE edge: a suppression in
* one extractor of the unified walk only REMOVES a phantom if the others
* also decline the node. The two files ask different questions of the same
* helper -- this one whether the node IS the head or a `when` wrapper above
* it, that one whether the node sits INSIDE the head -- so there is one
* definition of the head and they cannot drift apart. That containment
* stops at the head rather than covering the whole `when` operator, which
* is deliberate in the other direction: only the parameters being BOUND are
* excluded, and a parameter READ in the guard is a reference, exactly as
* the same read in the body is.
* - extract_unified.c, compute_elixir_func_qn, resolves a def's QN to open
* the function's call scope and reads the head through the same helper.
* Handed the `when` operator instead it returns NULL, no scope is pushed,
* and every call in a guarded body is sourced from the File rather than
* from the enclosing Function. That failure is independent of the two above
* because it decides edge SOURCE, not edge existence.
*
* Suppressing the head does not only delete edges; it moves metadata on edges
* that SURVIVE, and that is worth stating because a caller reading an edge's
* `line` will see a different number than before. `edges` is UNIQUE on
* (source, target, type), so where the def-head phantom happened to be the
* dedup survivor for a pair, deleting it promotes some other row for that same
* pair and the surviving edge takes that row's `line`.
*
* Measured by indexing one 970-file Elixir lib tree with the binary built from
* this commit's parent and with this one, and diffing the `edges` rows out of
* the two SQLite stores keyed on (source QN, target QN, type): 2,436 edges go
* and NONE is added -- 2,104 CALLS and 332 USAGE. Of the 119,696 edges that
* survive, 1,054 record a different `line`.
*
* The typespec attributes -- `@spec f(t) :: u` and the @callback / @type family
* -- put the declared name in a `call` node too, and are deliberately NOT
* suppressed here. A def head can be, because extract_usages.c already treats a
* def's first argument as a binding. Nothing there treats a typespec subject as
* one, so declining it in this walk would not remove its phantom, it would
* RELABEL it: handle_usages reaches the bare identifier handle_calls just
* declined and mints a USAGE onto the same function, which under
* UNIQUE(source, target, type) is a separate row that no longer collides with
* the real call it used to hide behind, and which pass_importance then counts.
* A typespec phantom has to be removed before any extractor sees it, by
* skipping the whole subtree in the unified walk. */
static bool elixir_call_is_definition_role(TSNode node, const char *source) {
static const char *const structural_heads[] = {"def", "defp", "defmacro", "defmodule", NULL};
static const char *const function_heads[] = {"def", "defp", "defmacro", NULL};
@@ -735,7 +795,7 @@ static bool elixir_call_is_definition_role(TSNode node, const char *source) {
TSNode signature = ts_node_named_child_count(arguments) > 0
? ts_node_named_child(arguments, 0)
: arguments;
return ts_node_eq(signature, node);
return cbm_elixir_def_head_is(signature, node);
}
return false;
}
@@ -757,7 +817,23 @@ static bool call_node_is_definition_container(CBMLanguage lang, TSNode node, con
if (lang == CBM_LANG_AGDA && strcmp(kind, "expr") == 0) {
return agda_expr_is_definition_role(node);
}
return lang == CBM_LANG_ELIXIR && strcmp(kind, "call") == 0 &&
/* A guarded head is a `when` binary_operator, not a `call`, and Elixir's
* call node types include binary_operator -- so the head reaches this walk
* and must be able to answer that it is a definition. A paren-less clause
* (`def f when g`) has no inner call at all, so the operator node itself
* reaches extract_callee_name and, with no callee of its own, takes that
* function's last resort -- the first identifier child -- minting a phantom
* CALLS edge onto the very function being defined.
*
* That last resort overrides a deliberate NULL for every Elixir
* binary_operator extract_scripting_callee declines (`=`, `<-`, `->`, `\\`,
* `::`, `when`), so `def g(c \\ 2)` still emits a phantom `c` and
* `e = target(d)` a phantom `e`. That is the pre-existing behaviour for
* those operators and is untouched here: only a `when` operator is
* admitted, so an operator definition's own head (`def a + b`) stays an
* ordinary node, as it was before guards were handled at all. */
return lang == CBM_LANG_ELIXIR &&
(strcmp(kind, "call") == 0 || cbm_elixir_is_when_guard(node)) &&
elixir_call_is_definition_role(node, source);
}
+161 -12
View File
@@ -8340,9 +8340,15 @@ static const char *cs_namespace_decl_name(CBMArena *a, TSNode ns, const char *so
/* Declared namespace of a C# type declaration: every enclosing namespace
* block (outer to inner) under an optional file-scoped namespace, which the
* grammar may attach either as an ancestor or as a preceding sibling of the
* compilation unit's members. NULL = global namespace. The file-level
* namespace_name keeps only the FIRST namespace of a file, which mislabels
* every type of a multi-namespace file (#2120). */
* compilation unit's members. "" = the global namespace, NULL = unknown. The
* file-level namespace_name keeps only the FIRST namespace of a file, which
* mislabels every type of a multi-namespace file (#2120).
*
* "No namespace found" proves the global namespace only for a clean parse:
* error recovery can dissolve a namespace declaration around a construct the
* grammar does not know (a `ref partial struct` with members does it), leaving
* the types after it parented straight under the compilation unit. Calling
* those global would let them hide every imported type of the same name. */
static const char *cs_type_decl_namespace(CBMArena *a, TSNode node, const char *source) {
const char *acc = NULL;
bool file_scoped = false;
@@ -8376,7 +8382,10 @@ static const char *cs_type_decl_namespace(CBMArena *a, TSNode node, const char *
}
}
}
return acc;
if (acc) {
return acc;
}
return ts_node_has_error(top) ? NULL : "";
}
static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) {
@@ -9714,8 +9723,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 +9847,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 +9868,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 +9923,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 +9949,17 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp
if (strcmp(macro, "def") == 0 || strcmp(macro, "defp") == 0 ||
strcmp(macro, "defmacro") == 0) {
extract_elixir_func_def(ctx, cur, macro);
extract_elixir_func_def(ctx, cur, macro, &def_scope);
} else if (strcmp(macro, "if") == 0 || strcmp(macro, "unless") == 0) {
/* A compile-time if/unless in a module body defines functions
* under a condition: its do and else branches hold variants of the
* same definitions (graph_buffer.c, "Definition variants"). The
* grammar nests the else_block inside the do_block. */
* grammar nests the else_block inside the do_block.
* The clause fold composes with this: fold_elixir_clause only
* folds a clause into one written in the same block, and each
* branch is its own block, so the clauses of one branch fold into
* one definition while the branches stay separate definitions that
* the graph buffer merges as variants of one identity. */
TSNode branch = cbm_find_child_by_kind(cur, "do_block");
for (int pass = 0; pass < 2 && !ts_node_is_null(branch); pass++) {
uint32_t bc = ts_node_child_count(branch);
+113 -2
View File
@@ -603,6 +603,11 @@ static const char *compute_elixir_func_qn(CBMExtractCtx *ctx, TSNode node) {
if (ts_node_is_null(first_arg)) {
return NULL;
}
/* A guard wraps the whole head in a `when` operator, so the head naming the
* function is its left operand. Left wrapped, this returns NULL and the def
* opens NO function scope: every call in a guarded body then sources to the
* FILE node and the function itself reports no outgoing edges. */
first_arg = cbm_elixir_def_head_unwrap_guard(first_arg);
const char *fk = ts_node_type(first_arg);
char *name = NULL;
if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) {
@@ -2154,6 +2159,111 @@ static bool is_unified_trivia_node(TSNode node) {
return ts_node_is_extra(node);
}
static bool unified_node_text_equals(const CBMExtractCtx *ctx, TSNode node, const char *expected) {
if (ts_node_is_null(node) || !ctx->source || !expected) {
return false;
}
uint32_t start = ts_node_start_byte(node);
uint32_t end = ts_node_end_byte(node);
size_t len = strlen(expected);
return end >= start && (size_t)(end - start) == len &&
memcmp(ctx->source + start, expected, len) == 0;
}
/* An Elixir module attribute parses as `unary_operator(@, call(<attr>, args))`,
* so the typespec family reaches the unified walk in the shape real code has:
* `@spec foo(t) :: t` puts `foo(t)` in the same `call` node an invocation would
* occupy, and `@type entry :: String.t()` does the same to a type name. The
* walk then reads a declaration as code and mints reference edges from it.
*
* Measured on a fixture of three typespec lines (`@type state :: map()`, `@spec
* fetch(opts) :: state`, `@spec load(User.t()) :: :ok`) beside `def state`,
* `def opts` and a remote `MyApp.User.t/0`: seven edges, every one phantom and
* every one landing on a Function. CALLS onto each specified function;
* CALLS + USAGE + WRITES onto `state`, the WRITES asserting a mutation that
* does not exist; and USAGE onto `opts` and onto `MyApp.User.t/0`, because a
* bare or remote TYPE name resolves to the same-named FUNCTION. On a codebase
* whose convention is @spec on every public function, fan_in therefore never
* reaches 0 and "which exported functions nothing calls" is unanswerable.
*
* The whole subtree is skipped, not just the specified name. What that costs is
* not uniform across the six heads, and both halves were measured by indexing
* one fixture repo -- a declaring file per head plus the file each one reaches
* -- with the pristine binary and with this one:
*
* spec, type, typep, opaque. A type reference resolves onto a FUNCTION or
* onto nothing, never onto the type it names, because a type declaration
* mints no node. `@spec build(MyApp.User.t())` where that module's `t` is a
* `@type` produced no edge in either build, and `@type ext :: Ecto.Schema.t()`
* naming a module outside the repo produced none either. The references that
* do resolve land on a same-named Function, cross-file included -- `USAGE
* lib/decl_type.ex -> Function type_target @lib/remote_type.ex`, and the
* @typep and @opaque equivalents. A Function standing in for a Type is the
* pollution rather than a record of it, so nothing correct is lost here.
*
* callback, macrocallback. The declared name IS a function name, so it
* resolves onto an implementation elsewhere in the corpus: pristine yields
* `CALLS lib/decl_callback.ex -> Function callback_fun @lib/impl_callback.ex`
* and the @macrocallback equivalent, both cross-file, and this build yields
* neither. That edge does point at a real Behaviour-to-Impl relationship, and
* nothing replaces it -- `@behaviour MyBehaviour` mints no edge of its own,
* so after this change nothing in the graph links a behaviour to its
* implementations. It is deleted anyway because it is name resolution and not
* a behaviour model: on a fixture with one @callback and three same-named
* `handle_it/1` defs, the single edge landed on the one module that
* implements nothing, leaving both real implementors at fan_in 0. Recording
* that relationship correctly means minting it from `@behaviour`, which is a
* separate change and not a reason to keep an arbitrary edge.
*
* The other accepted cost: `unquote(...)` inside a typespec does execute at
* compile time, so `@type t :: unquote(build_type())` loses its genuine
* `build_type` call edge along with the phantoms.
*
* Only this family is skipped. Every other attribute VALUE is ordinary
* compile-time code -- `@timeout Application.compile_env(:app, :timeout)`
* really does call compile_env -- so those subtrees are walked exactly as
* before, and their attribute NAME is still emitted as a callee: `@behaviour
* GenServer`, `@timeout 5_000` and `@doc "x"` each still mint one phantom CALLS
* onto a same-named Function. That half of the defect class is untouched here
* and needs its own fix.
*
* This subtree skip is one of two mechanisms, and neither subsumes the other.
* It removes the phantom a typespec LINE mints; the def-head phantom is removed
* separately, by the calls walk recognising a `when`-wrapped head as a
* declaration (elixir_call_is_definition_role, extract_calls.c). A function
* carrying both an @spec and a `when` clause needs both: with only this one it
* keeps the def-head reference, with only the other it keeps the @spec one.
* `extract_elixir_typespec_attribute_is_not_code` pins that by asserting 0
* references onto such a function, so removing either mechanism fails it.
*
* Measured over forge-symphony-graph/lib (970 .ex files) by classifying the
* source line every CALLS/USAGE/WRITES edge records: a build without this skip
* anchors 4,565 CALLS edges on a typespec-attribute line, and this build
* anchors 0 -- with no USAGE or WRITES appearing in their place, which is what
* a relabel rather than a removal would look like. Total USAGE edges fall from
* 25,393 to 23,044 over the same corpus; they do not rise. */
static bool is_elixir_typespec_attribute(const CBMExtractCtx *ctx, TSNode node) {
static const char *const typespec_heads[] = {
"spec", "callback", "macrocallback", "type", "typep", "opaque", NULL};
if (ctx->language != CBM_LANG_ELIXIR || strcmp(ts_node_type(node), "unary_operator") != 0 ||
!unified_node_text_equals(ctx, ts_node_child_by_field_name(node, TS_FIELD("operator")),
"@")) {
return false;
}
TSNode operand = ts_node_child_by_field_name(node, TS_FIELD("operand"));
if (ts_node_is_null(operand) || strcmp(ts_node_type(operand), "call") != 0 ||
ts_node_child_count(operand) == 0) {
return false;
}
TSNode head = ts_node_child(operand, 0);
for (const char *const *name = typespec_heads; *name; name++) {
if (unified_node_text_equals(ctx, head, *name)) {
return true;
}
}
return false;
}
// JS/TS `export_statement` appears in import_node_types so re-exports
// (`export { X } from './m'`) are treated as an import boundary. But it also
// wraps exported *declarations* (`export function f(cfg: Config) {}`), and
@@ -2755,7 +2865,8 @@ void cbm_extract_unified(CBMExtractCtx *ctx) {
break;
}
bool trivia = is_unified_trivia_node(node);
if (!trivia) {
bool typespec = is_elixir_typespec_attribute(ctx, node);
if (!trivia && !typespec) {
/* Trivia consumes no semantic state. Scope expiry may be deferred
* until the next code-bearing node; pop restores the displaced
* tuple and push applies the new frame's effect, both O(1) --
@@ -2788,7 +2899,7 @@ void cbm_extract_unified(CBMExtractCtx *ctx) {
* asking the cursor to construct a child iterator for every one of
* hundreds of thousands of flat comment siblings. Structured extras
* still descend normally. */
if ((!trivia || ts_node_child_count(node) > 0) &&
if (!typespec && (!trivia || ts_node_child_count(node) > 0) &&
ts_tree_cursor_goto_first_child(&cursor)) {
depth++;
continue;
+5
View File
@@ -617,6 +617,11 @@ static bool is_elixir_def_binding(CBMExtractCtx *ctx, TSNode node) {
TSNode signature = ts_node_named_child_count(arguments) > 0
? ts_node_named_child(arguments, 0)
: arguments;
/* A guard makes that signature the whole `when` operator, which spans the
* guard expression as well as the head. Unwrapped to the head, only the
* parameters being BOUND are excluded here; a parameter READ inside the
* guard stays a usage, as the same read in the body already is. */
signature = cbm_elixir_def_head_unwrap_guard(signature);
return node_contains(head, node) || node_contains(signature, node);
}
return false;
+87
View File
@@ -1382,6 +1382,93 @@ const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, c
return scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name;
}
/* ── Elixir def-head guards ─────────────────────────────────── */
/* The guard sits on the head, not beside it: `def name(args) when guard` parses
* as a single `when` binary_operator whose left operand is `name(args)`. The
* operator field is an exact anonymous token, so the check is the token type
* rather than the node kind — `def a + b` is a binary_operator head too, and
* unwrapping it would name the function after its left parameter. */
bool cbm_elixir_is_when_guard(TSNode node) {
if (ts_node_is_null(node) || strcmp(ts_node_type(node), "binary_operator") != 0) {
return false;
}
TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator"));
return !ts_node_is_null(op) && strcmp(ts_node_type(op), "when") == 0;
}
/* One `when` level off a def's first argument, or the node unchanged.
*
* There is deliberately no named-child fallback for a missing `left`. An infix
* operator cannot reduce without a left operand, so tree-sitter-elixir never
* builds a `when` binary_operator that lacks one: measured over every guarded
* shape in the suite plus a corpus of truncated heads (`def when true`,
* `def f(x) when`, `def f(x) when when is_x(x)`, `def when() when when`),
* `left` was present on all 66 unwraps, ERROR-recovered parses included, and
* every one had the left operand as named child 0. Where the left operand was
* genuinely absent the grammar produced no `when` operator at all -- it read
* `when` as the head identifier -- so the unwrap is never entered. A
* named-child fallback would therefore be dead on a well-formed tree and, on
* the hypothetical malformed one, would hand back named child 0 without
* knowing it is the left operand rather than the guard expression: naming the
* function after its own guard. Returning the node unchanged degrades to the
* pre-guard behaviour instead, which is the safe direction. */
static TSNode elixir_unwrap_one_guard(TSNode first_arg) {
if (!cbm_elixir_is_when_guard(first_arg)) {
return first_arg;
}
TSNode lhs = ts_node_child_by_field_name(first_arg, TS_FIELD("left"));
return ts_node_is_null(lhs) ? first_arg : lhs;
}
/* The head under every guard a def's first argument carries, or first_arg
* unchanged when it carries none. Elixir admits more than one guard on a
* clause -- `def f(x) when is_atom(x) when is_binary(x)` -- and
* tree-sitter-elixir nests those RIGHT-associatively, measured on that exact
* source: `when(f(x), when(is_atom(x), is_binary(x)))`. The declared head is
* therefore the outer operator's left operand and one peel reaches it. The
* peel still runs to a fixed point: the extra iteration is one token compare,
* and it is what makes the helper's contract "the head under EVERY guard"
* rather than "the head under the first one", which is the property the three
* walks rely on.
*
* Left wrapped, the defs walk drops the clause, the unified walk opens no
* function scope (so every call in a guarded body sources to the FILE node),
* the calls walk stops recognising the head and emits it as an invocation of
* the very function being defined, and the usages walk swallows every
* identifier in the guard as part of the binding. Four walks read this same
* argument, which is why the unwrap lives here rather than in any one of
* them. */
TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg) {
for (;;) {
TSNode next = elixir_unwrap_one_guard(first_arg);
if (ts_node_eq(next, first_arg)) {
return first_arg;
}
first_arg = next;
}
}
/* True when `node` is the head a def declares, or any of the `when` guard
* operators wrapping it. The calls walk needs the whole chain, not just its
* ends: each member reaches that walk by its own route -- `def f(x) when g`
* arrives as the inner `f(x)` call, `def f when g` has no inner call at all and
* the operator itself falls through to extract_callee_name's first-identifier
* last resort -- and a member it fails to recognise as a declaration is emitted
* as an invocation of the function being defined. */
bool cbm_elixir_def_head_is(TSNode signature, TSNode node) {
for (;;) {
if (ts_node_eq(signature, node)) {
return true;
}
TSNode next = elixir_unwrap_one_guard(signature);
if (ts_node_eq(next, signature)) {
return false;
}
signature = next;
}
}
static const char *func_node_name(CBMArena *a, TSNode func_node, const char *source,
CBMLanguage lang) {
// Wolfram: set_delayed_top/set_top/set_delayed/set — LHS is apply(user_symbol("f"), ...)
+20
View File
@@ -157,6 +157,26 @@ const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char
// attrpath and an enclosing attrset compose into one qualified name.
const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name);
// ── Elixir def-head guards ──
// `def f(x) when g` parses its WHOLE head as a `when` binary_operator, so the
// `f(x)` call naming the function is the operator's left operand, and more than
// one guard (`when a when b`) nests those operators right-associatively, so the
// head stays the outermost operator's left operand in either shape. Four
// walks read that same first argument — the defs walk names the function from
// it, the unified walk opens the function's call scope from it, the calls walk
// tells a head apart from an invocation by it, and the usages walk excludes the
// head's identifiers from the usage set by it — so a private copy in one of
// them makes the rest disagree about what a guarded clause is.
// cbm_elixir_is_when_guard answers "is this node a guarded head"; the unwrap
// returns the head under every guard, or the node unchanged when it carries
// none; cbm_elixir_def_head_is answers whether a node is that head or one of
// the guard operators above it, which is the question the calls walk asks.
// All three test the operator token, so an operator definition (`def a + b`) is
// left alone rather than mis-read as a guard.
bool cbm_elixir_is_when_guard(TSNode node);
TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg);
bool cbm_elixir_def_head_is(TSNode signature, TSNode node);
// Resolve a function/method definition node's NAME node across all ~130 grammars
// (generic `name` field, arrow→declarator, C/C++ declarator chain, plus the many
// per-language quirks: Fortran subroutine, SCSS mixin, SQL create_function, R,
+95 -22
View File
@@ -51,6 +51,10 @@
#define CS_USING_INITIAL_CAP 16
#define CS_NAMESPACE_INITIAL_CAP 8
#define CS_LSP_PARENT_WALK_MAX 32
/* Nested-type qualifier walk depth: one level per type nesting step of `A.B.C`
* (a namespace-qualified top-level type binds without recursing), so 8 covers
* real nesting while a cyclic alias such as `using A = A.B;` stops after 8. */
#define CS_QUALIFIER_MAX_DEPTH 8
extern const TSLanguage *tree_sitter_c_sharp(void);
@@ -257,11 +261,15 @@ void cs_lsp_init(CSLSPContext *ctx, CBMArena *arena, const char *source, int sou
/* ── using management ───────────────────────────────────────────── */
void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_name,
const char *target_qn, bool is_global) {
/* Append a using directive; `in_namespace` marks one declared inside a
* namespace body (CBMCSUsing.in_namespace). */
static void cs_add_using_scoped(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_name,
const char *target_qn, bool is_global, bool in_namespace) {
if (!target_qn)
return;
/* Dedupe by (kind, local_name, target). */
/* Dedupe by (kind, local_name, target). The cross-file entry pre-loads
* the pipeline's import list without placement; the AST walk then finds
* the same directive and is the authority on where it was declared. */
for (int i = 0; i < ctx->using_count; i++) {
CBMCSUsing *u = &ctx->usings[i];
if (u->kind != kind)
@@ -272,6 +280,7 @@ void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_
const char *b = u->local_name ? u->local_name : "";
if (strcmp(a, b) != 0)
continue;
u->in_namespace = u->in_namespace || in_namespace;
return;
}
if (ctx->using_count >= ctx->using_cap) {
@@ -289,6 +298,12 @@ void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_
slot->local_name = local_name ? cbm_arena_strdup(ctx->arena, local_name) : "";
slot->target_qn = cbm_arena_strdup(ctx->arena, target_qn);
slot->is_global = is_global;
slot->in_namespace = in_namespace;
}
void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_name,
const char *target_qn, bool is_global) {
cs_add_using_scoped(ctx, kind, local_name, target_qn, is_global, false);
}
/* ── namespace stack ────────────────────────────────────────────── */
@@ -358,12 +373,29 @@ static const char *cs_try_type_qn(CSLSPContext *ctx, const char *qn) {
return NULL;
}
/* Append the file's using-namespace targets declared inside (`in_namespace`)
* or outside a namespace body. */
static int cs_append_using_namespaces(const CSLSPContext *ctx, bool in_namespace, const char **out,
int n, int cap) {
for (int i = 0; i < ctx->using_count && n < cap; i++) {
const CBMCSUsing *u = &ctx->usings[i];
if (u->kind == CBM_CS_USING_NAMESPACE && u->in_namespace == in_namespace && u->target_qn &&
u->target_qn[0])
out[n++] = u->target_qn;
}
return n;
}
/* Namespaces through which `qualifier` (the part of a type reference before
* its simple name, NULL for a simple name) can reach a top-level type, in C#
* lookup order: each enclosing namespace innermost outward, then the global
* namespace (qualified references only) or the file's `using` namespaces
* (simple names only -- a using-namespace directive does not import nested
* namespaces). Returns the count written to `out` (at most `cap`). */
* namespace (qualified references only) or, for a simple name, the usings
* of the enclosing namespace bodies, the global namespace "" and last the
* compilation-unit usings (a using-namespace directive does not import nested
* namespaces). C# consults the members of the global namespace BEFORE the
* compilation unit's usings: a top-level type declared without a namespace
* hides an imported type of the same name. Returns the count written to
* `out` (at most `cap`). */
static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const char **out,
int cap) {
int n = 0;
@@ -378,12 +410,13 @@ static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const
out[n++] = qualifier;
return n;
}
for (int i = 0; i < ctx->using_count && n < cap; i++) {
const CBMCSUsing *u = &ctx->usings[i];
if (u->kind == CBM_CS_USING_NAMESPACE && u->target_qn && u->target_qn[0])
out[n++] = u->target_qn;
}
return n;
n = cs_append_using_namespaces(ctx, true, out, n, cap);
/* The global namespace (#2120 residual: such types used to be invisible
* to step 8b) ranks after every enclosing namespace and namespace-body
* using, and before the compilation-unit usings. */
if (n < cap)
out[n++] = "";
return cs_append_using_namespaces(ctx, false, out, n, cap);
}
/* Bind a (possibly namespace-qualified) type reference to the project type
@@ -421,6 +454,24 @@ static const char *cs_resolve_by_declared_namespace(CSLSPContext *ctx, const cha
}
}
}
/* No namespace-qualified hit: a qualified `A.B` may name a NESTED type —
* A a type in scope (resolved recursively, one segment shorter), B a
* member type of it. Nested QNs are the parent QN plus the simple name,
* which step 8b's namespace scan can never produce because nested types
* carry no namespace_qn (#2120 residual: `new Command.Result(...)`,
* `new Grain.Handler(dep).Handle(...)`). The walk is depth-bounded: an
* alias whose target names the alias itself (`using A = A.B;`, or a
* mutual pair) re-enters this path through step 8 with no end. */
if (!best && qualifier && ctx->qualifier_depth < CS_QUALIFIER_MAX_DEPTH) {
ctx->qualifier_depth++;
const char *qtype = cs_resolve_type_name(ctx, qualifier);
ctx->qualifier_depth--;
if (qtype) {
const char *nested = cbm_arena_sprintf(ctx->arena, "%s.%s", qtype, simple);
if (cs_lookup_type_qn(ctx, nested))
return nested;
}
}
return best ? best->qualified_name : NULL;
}
@@ -610,10 +661,14 @@ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) {
}
/* Other top-level declarations of the type `t` names: same simple name, same
* declared namespace, different entry. Registration order; at most `cap`. */
* declared namespace, different entry. Registration order; at most `cap`.
* A type in the global namespace ("") has no twins: without a namespace the
* same name across a repository is a different type per assembly (every test
* project's own `Program`), so pooling them would bind a member of one to an
* unrelated other, ahead of the type's own base classes. */
static int cs_namespace_twins(CSLSPContext *ctx, const CBMRegisteredType *t,
const CBMRegisteredType **out, int cap) {
if (!ctx->registry || !t || !t->namespace_qn || !t->short_name)
if (!ctx->registry || !t || !t->namespace_qn || !t->namespace_qn[0] || !t->short_name)
return 0;
int n = 0;
CBMTypeShortIter it;
@@ -2797,12 +2852,19 @@ static void cs_process_type_decl(CSLSPContext *ctx, TSNode node) {
static void cs_collect_imports(CSLSPContext *ctx, TSNode root) {
/* Walk the entire tree once at top — using directives can appear at file
* scope or inside namespace blocks. */
TSNode stack[256];
* scope or inside namespace blocks. `in_ns` records per stacked node
* whether it sits inside a namespace declaration: a block namespace's
* body, or the compilation-unit siblings after a file-scoped namespace
* (the grammar may attach those either way). */
enum { CS_IMPORT_WALK_MAX = 256 };
TSNode stack[CS_IMPORT_WALK_MAX];
bool in_ns[CS_IMPORT_WALK_MAX];
int top = 0;
in_ns[top] = false;
stack[top++] = root;
while (top > 0) {
TSNode n = stack[--top];
bool n_in_ns = in_ns[top];
if (ts_node_is_null(n))
continue;
const char *k = ts_node_type(n);
@@ -2886,20 +2948,23 @@ static void cs_collect_imports(CSLSPContext *ctx, TSNode root) {
}
if (target) {
if (is_alias) {
cs_lsp_add_using(ctx, CBM_CS_USING_ALIAS, alias_name ? alias_name : "", target,
is_global);
cs_add_using_scoped(ctx, CBM_CS_USING_ALIAS, alias_name ? alias_name : "",
target, is_global, n_in_ns);
} else if (is_static) {
cs_lsp_add_using(ctx, CBM_CS_USING_STATIC, "", target, is_global);
cs_add_using_scoped(ctx, CBM_CS_USING_STATIC, "", target, is_global, n_in_ns);
} else {
cs_lsp_add_using(ctx, CBM_CS_USING_NAMESPACE, "", target, is_global);
cs_add_using_scoped(ctx, CBM_CS_USING_NAMESPACE, "", target, is_global,
n_in_ns);
}
}
}
/* Push children for traversal. We don't recurse into method bodies
* (using directives can't appear there), but namespace bodies may
* contain more usings. */
bool child_in_ns = n_in_ns || strcmp(k, "namespace_declaration") == 0 ||
strcmp(k, "file_scoped_namespace_declaration") == 0;
uint32_t cnc = ts_node_child_count(n);
for (uint32_t i = 0; i < cnc && top < 256; i++) {
for (uint32_t i = 0; i < cnc && top < CS_IMPORT_WALK_MAX; i++) {
TSNode c = ts_node_child(n, i);
if (ts_node_is_null(c))
continue;
@@ -2908,7 +2973,11 @@ static void cs_collect_imports(CSLSPContext *ctx, TSNode root) {
strcmp(ck, "constructor_declaration") == 0 || strcmp(ck, "block") == 0) {
continue;
}
in_ns[top] = child_in_ns;
stack[top++] = c;
/* A file-scoped namespace covers the siblings that follow it. */
if (strcmp(ck, "file_scoped_namespace_declaration") == 0)
child_in_ns = true;
}
}
}
@@ -3749,12 +3818,16 @@ void cbm_run_cs_lsp(CBMArena *arena, CBMFileResult *result, const char *source,
* type is not reachable by its simple name through a namespace). */
static const char *cs_top_level_namespace(const char *type_qn, const char *short_name,
const char *module_qn, const char *namespace_name) {
if (!type_qn || !short_name || !module_qn || !namespace_name || !namespace_name[0])
if (!type_qn || !short_name || !module_qn)
return NULL;
size_t mlen = strlen(module_qn);
if (strncmp(type_qn, module_qn, mlen) != 0 || type_qn[mlen] != '.' ||
strcmp(type_qn + mlen + 1, short_name) != 0)
return NULL;
/* Top-level C# type: its declared namespace, "" for the global namespace
* so step 8b can bind it, NULL when extraction could not tell (a parse
* error dissolved the namespace; see cs_type_decl_namespace). NULL stays
* invisible to step 8b, as do nested types. */
return namespace_name;
}
+10
View File
@@ -61,6 +61,11 @@ typedef struct {
const char *local_name; /* alias name; "" for non-alias */
const char *target_qn; /* dotted target QN */
bool is_global; /* `global using` */
/* Declared inside a namespace body rather than at compilation-unit level.
* C# consults a namespace declaration's usings right after that
* namespace's own members, but compilation-unit usings only after the
* global namespace's members, so the two rank on opposite sides of it. */
bool in_namespace;
} CBMCSUsing;
/* CSLSPContext — per-file type-evaluation state. */
@@ -111,6 +116,11 @@ typedef struct {
/* Recursion guard for cs_eval_expr_type. */
int eval_depth;
/* Recursion guard for the nested-type qualifier walk of
* cs_resolve_type_name: resolving `A.B` resolves the qualifier `A` as a
* type first, and a `using A = A.B;` alias cycles back into it. */
int qualifier_depth;
/* Debug mode (CBM_LSP_DEBUG env). */
bool debug;
} CSLSPContext;
+19
View File
@@ -46,6 +46,25 @@ static const lib_pattern_t http_libraries[] = {
* case-sensitive, so without this entry `Axios.get(url)` fell through to
* the `.get` route-suffix fallback and became a route registration. */
{"Axios", CBM_SVC_HTTP, NULL},
/* Angular HttpClient (#2768). The receiver rows end in a dot, like "gin.":
* a bare "this.http" / "$http" would also match this.httpAdapter.get
* (NestJS route registration) and $httpBackend.expectGET (an AngularJS test
* double), because an uppercase letter after an id counts as CamelCase
* glue. A chain wrapped before the verb keeps its line break in the callee
* text ("this.http\n .post", prettier's style), so each receiver also
* has a line-break twin: LF, and CR for CRLF files. Java/C# HttpClient is
* already listed below. */
{"@angular/common/http", CBM_SVC_HTTP, NULL},
{"angular.common.http", CBM_SVC_HTTP, NULL},
{"this.http.", CBM_SVC_HTTP, NULL},
{"this.http\n", CBM_SVC_HTTP, NULL},
{"this.http\r", CBM_SVC_HTTP, NULL},
{"httpClient.", CBM_SVC_HTTP, NULL},
{"httpClient\n", CBM_SVC_HTTP, NULL},
{"httpClient\r", CBM_SVC_HTTP, NULL},
{"$http.", CBM_SVC_HTTP, NULL},
{"$http\n", CBM_SVC_HTTP, NULL},
{"$http\r", CBM_SVC_HTTP, NULL},
{"superagent", CBM_SVC_HTTP, NULL},
{"needle", CBM_SVC_HTTP, NULL},
{"node-fetch", CBM_SVC_HTTP, NULL},
+2 -2
View File
@@ -74,9 +74,9 @@ src/pipeline/pass_semantic.c 3
src/pipeline/pass_tests.c 3
src/pipeline/pass_usages.c 4
src/pipeline/path_alias.c 40
src/pipeline/pipeline.c 132
src/pipeline/pipeline.c 129
src/pipeline/pipeline_delta.c 19
src/pipeline/pipeline_incremental.c 140
src/pipeline/pipeline_incremental.c 130
src/pipeline/registry.c 17
src/pipeline/worker_pool.c 2
src/store/store.c 354
+76 -5
View File
@@ -2969,7 +2969,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.
@@ -7713,6 +7732,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) {
@@ -7727,6 +7753,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++;
@@ -7750,9 +7777,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);
@@ -7766,6 +7802,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);
@@ -10058,6 +10099,35 @@ static void install_devin_durable_context(const cbm_agent_registry_context_t *re
static void install_generated_client_extension(const char *label, const char *path,
const char *binary_path,
char *(*generate)(const char *), bool dry_run) {
/* #2204: a module that already carries content but none of our markers is
* the user's. The generated header promises "remove the markers to take
* ownership", and appending our prelude to a foreign module produces a file
* the client cannot even parse — the prelude redeclares the user's own
* import and const names, and both sides declare a default export. Probe
* ownership before generating so the preview names the refusal instead of
* promising a write the run will not make. A probe failure (-1) falls
* through to the write, which reports it through the usual error path. */
bool foreign =
cbm_text_managed_block_status(path, CBM_ADAPTER_MARKER_START, CBM_ADAPTER_MARKER_END) == 1;
if (foreign) {
/* The --plan receipt (#388) lists only writes the run will make, and
* its stdout carries the receipt JSON, so plan mode records nothing
* and prints nothing here. The dry run names the skip instead. */
if (g_install_plan) {
return;
}
if (dry_run) {
printf(" extension: %s would be skipped — the file there is not ours (no "
"codebase-memory-mcp markers), so it is left untouched; delete it or add the "
"markers to let the installer manage it\n",
path);
} else {
printf(" extension: %s left untouched — the file there is not ours (no "
"codebase-memory-mcp markers)\n",
path);
}
return;
}
if (g_install_plan) {
plan_record(label, "extension", path);
return;
@@ -11265,7 +11335,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++;
}
}
+73 -13
View File
@@ -1020,6 +1020,42 @@ static int text_validate_markers(const char *begin_marker, const char *end_marke
return TEXT_OK;
}
/* A managed document as read from disk, with our block located in it. */
typedef struct {
char *data;
size_t len;
size_t bom_len;
text_file_snapshot_t snapshot;
text_managed_region_t region;
} text_managed_document_t;
/* The read-validate-scan step shared by the upsert and the read-only ownership
* probe, so both judge a file by the same rules. An absent file reads as an
* empty document. On success the caller owns doc->data and releases it; on
* failure nothing is left allocated. */
static int text_read_managed_document(const char *file_path, const char *begin_marker,
size_t begin_len, const char *end_marker, size_t end_len,
text_managed_document_t *doc) {
doc->data = NULL;
doc->len = 0U;
doc->bom_len = 0U;
int status = text_read_file(file_path, &doc->data, &doc->len, &doc->snapshot);
if (status == TEXT_OK) {
status = text_validate_bytes(doc->data, doc->len, 1);
}
if (status == TEXT_OK) {
doc->bom_len = text_bom_length(doc->data, doc->len);
status = text_scan_managed_region(doc->data, doc->len, doc->bom_len, begin_marker,
begin_len, end_marker, end_len, &doc->region);
}
if (status != TEXT_OK) {
free(doc->data);
doc->data = NULL;
return TEXT_ERROR;
}
return TEXT_OK;
}
static int text_upsert_managed_block(const char *file_path, const char *begin_marker,
const char *end_marker, const char *owned_content,
size_t max_document_bytes) {
@@ -1036,21 +1072,16 @@ static int text_upsert_managed_block(const char *file_path, const char *begin_ma
return TEXT_ERROR;
}
char *old_data = NULL;
size_t old_len = 0U;
text_file_snapshot_t snapshot;
if (text_read_file(file_path, &old_data, &old_len, &snapshot) != TEXT_OK ||
text_validate_bytes(old_data, old_len, 1) != TEXT_OK) {
free(old_data);
return TEXT_ERROR;
}
size_t bom_len = text_bom_length(old_data, old_len);
text_managed_region_t region;
if (text_scan_managed_region(old_data, old_len, bom_len, begin_marker, begin_len, end_marker,
end_len, &region) != TEXT_OK) {
free(old_data);
text_managed_document_t doc;
int read_status =
text_read_managed_document(file_path, begin_marker, begin_len, end_marker, end_len, &doc);
if (read_status != TEXT_OK) {
return TEXT_ERROR;
}
char *old_data = doc.data;
size_t old_len = doc.len;
text_file_snapshot_t snapshot = doc.snapshot;
text_managed_region_t region = doc.region;
text_buffer_t updated = {0};
int result = TEXT_ERROR;
@@ -1070,6 +1101,7 @@ static int text_upsert_managed_block(const char *file_path, const char *begin_ma
&snapshot);
}
} else {
size_t bom_len = doc.bom_len;
const char *eol = NULL;
size_t eol_len = 0U;
text_detect_eol(old_data + bom_len, old_len - bom_len, &eol, &eol_len);
@@ -1105,6 +1137,34 @@ int cbm_text_upsert_managed_block_limited(const char *file_path, const char *beg
max_document_bytes);
}
int cbm_text_managed_block_status(const char *file_path, const char *begin_marker,
const char *end_marker) {
size_t begin_len = 0U;
size_t end_len = 0U;
if (!text_valid_path(file_path) ||
text_validate_markers(begin_marker, end_marker, &begin_len, &end_len) != TEXT_OK) {
return TEXT_ERROR;
}
text_managed_document_t doc;
int read_status =
text_read_managed_document(file_path, begin_marker, begin_len, end_marker, end_len, &doc);
if (read_status != TEXT_OK) {
return TEXT_ERROR;
}
int result = TEXT_OK;
if (!doc.region.present) {
for (size_t pos = doc.bom_len; pos < doc.len; pos++) {
char byte = doc.data[pos];
if (byte != ' ' && byte != '\t' && byte != '\r' && byte != '\n') {
result = TEXT_UNOWNED;
break;
}
}
}
free(doc.data);
return result;
}
int cbm_text_remove_managed_block(const char *file_path, const char *begin_marker,
const char *end_marker) {
size_t begin_len = 0U;
+10
View File
@@ -11,6 +11,16 @@ int cbm_text_upsert_managed_block(const char *file_path, const char *begin_marke
int cbm_text_upsert_managed_block_limited(const char *file_path, const char *begin_marker,
const char *end_marker, const char *owned_content,
size_t max_document_bytes);
/* Read-only ownership probe for a managed block, used before writing a file a
* client auto-loads as a module. Returns 0 when the block may be written (no
* file, a file whose content is only whitespace, or a file already carrying our
* markers), 1 when the file carries content but none of our markers — the
* caller must leave the user's file alone and say so rather than append to it,
* because the generated block redeclares module-level names a hand-ported
* module also declares (#2204) — and -1 for unsafe state or I/O failure. Never
* writes, so a dry run may call it. */
int cbm_text_managed_block_status(const char *file_path, const char *begin_marker,
const char *end_marker);
int cbm_text_remove_managed_block(const char *file_path, const char *begin_marker,
const char *end_marker);
+9 -5
View File
@@ -2146,6 +2146,9 @@ static bool application_index_args_normalize_defaults(yyjson_mut_val *root) {
if (name && (!yyjson_mut_is_str(name) || yyjson_mut_get_len(name) == 0)) {
(void)yyjson_mut_obj_remove_key(root, "name");
}
/* Automatic jobs carry _cbm_background, which picks the worker count and
* never what is indexed, so an explicit request joins them. */
(void)yyjson_mut_obj_remove_key(root, "_cbm_background");
return true;
}
@@ -2559,10 +2562,10 @@ static char *application_auto_index_args(cbm_daemon_application_t *application,
return NULL;
}
yyjson_mut_doc_set_root(document, root);
char *args = yyjson_mut_obj_add_strcpy(document, root, "repo_path", root_path) &&
application_index_args_add_policy(application, document, root)
? yyjson_mut_write(document, 0, NULL)
: NULL;
bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", root_path) &&
application_index_args_add_policy(application, document, root) &&
yyjson_mut_obj_add_bool(document, root, "_cbm_background", true);
char *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL;
yyjson_mut_doc_free(document);
return args;
}
@@ -4423,7 +4426,8 @@ static int application_background_index(cbm_daemon_application_t *application,
}
yyjson_mut_doc_set_root(document, root);
bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", canonical_root) &&
application_index_args_add_policy(application, document, root);
application_index_args_add_policy(application, document, root) &&
yyjson_mut_obj_add_bool(document, root, "_cbm_background", true);
char *default_project = cbm_project_name_from_path(canonical_root);
bool custom_project =
project_name[0] && (!default_project || strcmp(default_project, project_name) != 0);
+8
View File
@@ -317,7 +317,9 @@ bool cbm_is_internal_cache_db(const char *filename) {
bool cbm_is_project_index_db(const char *filename) {
static const char db_ext[] = ".db";
static const char adr_ext[] = ".adr.db";
const size_t ext_len = sizeof(db_ext) - 1;
const size_t adr_len = sizeof(adr_ext) - 1;
if (!filename) {
return false;
}
@@ -325,6 +327,12 @@ bool cbm_is_project_index_db(const char *filename) {
if (len <= ext_len || strcmp(filename + len - ext_len, db_ext) != 0) {
return false;
}
/* A per-project ADR sidecar ("<db>.adr.db") ends in ".db" but is not itself
* an index — exclude it from every index scan/count (MCP loops + the CLI's
* cbm_list_indexes / count_db_indexes all gate on this helper). */
if (len >= adr_len && strcmp(filename + len - adr_len, adr_ext) == 0) {
return false;
}
return !cbm_is_internal_cache_db(filename);
}
+51 -6
View File
@@ -769,7 +769,7 @@ static const tool_def_t TOOLS[] = {
"\"offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0},"
"\"metadata_only\":{\"type\":\"boolean\",\"default\":false,"
"\"description\":\"Compatibility: omit counts, size, and branch.\"}}}"},
{"delete_project", "Delete a project from the index",
{"delete_project", "Delete a project from the index, including its ADR store",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
"\"project\"]}"},
@@ -7885,7 +7885,9 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
return result;
}
/* delete_project: just erase the .db file (and WAL/SHM). */
/* delete_project: erase the .db file (and WAL/SHM) plus the ADR sidecar
* "<db>.adr.db" (and its WAL/SHM/journal), so deleting a project removes its
* ADR store with it. */
static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) {
char *name = get_project_arg(args);
if (!name) {
@@ -7924,18 +7926,37 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) {
const char *error_detail = NULL;
bool is_error = false;
/* ADR sidecar lives beside the graph DB as "<db>.adr.db" — remove it (and
* its own WAL/SHM/journal) whenever the project is deleted. Sized as the
* graph path plus the ".adr.db" suffix. */
char adr_path[CBM_SZ_1K + sizeof(".adr.db")];
int adr_n = snprintf(adr_path, sizeof(adr_path), "%s.adr.db", path);
bool have_adr_path = adr_n > 0 && (size_t)adr_n < sizeof(adr_path);
if (exists) {
int rc = cbm_unlink(path);
(void)cbm_unlink(wal);
(void)cbm_unlink(shm);
if (rc == 0) {
status = "deleted";
/* Only drop the ADR store once the graph DB is actually gone, so a
* failed graph delete leaves the project (and its ADR) intact. */
if (have_adr_path) {
(void)cbm_unlink(adr_path);
(void)cbm_remove_db_sidecars(adr_path);
}
} else {
status = "delete_failed";
error_detail = strerror(errno);
is_error = true;
}
} else {
/* No graph DB, but a sidecar could still linger (e.g. ADR written then
* the index deleted out of band) — best-effort clean it up. */
if (have_adr_path) {
(void)cbm_unlink(adr_path);
(void)cbm_remove_db_sidecars(adr_path);
}
is_error = true;
}
@@ -12449,7 +12470,8 @@ static char *index_run_supervised_path(cbm_mcp_server_t *srv, const char *root_p
* worker reads no config); unset, the worker's index mode decides. */
if (!yyjson_mut_obj_add_strcpy(doc, root, "repo_path", root_path) ||
!cbm_mcp_index_policy_add_to_args(doc, root, &policy) ||
!cbm_mcp_index_doc_candidates_add_to_args(doc, root, srv ? srv->config : NULL)) {
!cbm_mcp_index_doc_candidates_add_to_args(doc, root, srv ? srv->config : NULL) ||
!yyjson_mut_obj_add_bool(doc, root, "_cbm_background", true)) {
yyjson_mut_doc_free(doc);
return NULL;
}
@@ -12752,6 +12774,22 @@ static bool index_root_owner_resolve(const char *repo_path, char **owner_out, ch
return false;
}
/* Background CPU headroom is for a refresh of a graph that already serves.
* A project's first index keeps every core, even when it was started
* automatically: the caller is waiting on a graph that does not exist yet.
* An empty or unreadable file is not a committed index. Both sites that
* honour automatic indexing — handle_index_repository's _cbm_background
* flag and the in-process autoindex_thread — go through this so they
* cannot drift. */
static bool project_has_committed_index(const char *project) {
if (!project || !project[0]) {
return false;
}
char db_path[CBM_SZ_1K];
project_db_path(project, db_path, sizeof(db_path));
return db_path[0] && project_db_is_servable(project, db_path);
}
/* The three heap strings handle_index_repository owns from
* cbm_mcp_get_string_arg / resolved_repo_path_from_project_arg. One release
* point keeps the dozen early-return paths in step; free(NULL) is a no-op, so
@@ -12881,6 +12919,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
char *repo_path = cbm_mcp_get_string_arg(args, "repo_path");
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
char *name_override = cbm_mcp_get_string_arg(args, "name");
bool background = cbm_mcp_get_bool_arg(args, "_cbm_background");
cbm_normalize_path_sep(repo_path);
if (!repo_path) {
@@ -13053,6 +13092,10 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
return cbm_mcp_text_result("invalid project name", true);
}
free(name_override);
/* Headroom follows the final project name, decided before artifact
* bootstrap can create the first local copy of this index. */
cbm_pipeline_set_background(p, background &&
project_has_committed_index(cbm_pipeline_project_name(p)));
cbm_pipeline_set_persistence(p, persistence);
cbm_pipeline_set_resource_policy(p, &resource_policy);
index_doc_candidates_apply(p, doc_candidates);
@@ -20792,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);
@@ -21462,6 +21506,7 @@ static void *autoindex_thread(void *arg) {
return NULL;
}
index_doc_candidates_apply(p, index_doc_candidates_choice(srv, NULL));
cbm_pipeline_set_background(p, project_has_committed_index(cbm_pipeline_project_name(p)));
/* Block until any concurrent pipeline finishes */
cbm_pipeline_lock();
+91 -8
View File
@@ -884,13 +884,54 @@ static void artifact_snapshot_tmp_close(artifact_snapshot_tmp_t *tmp) {
tmp->dir[0] = '\0';
}
/* Make the snapshot's project_summaries carry exactly the current ADR — and
* nothing stale. ADRs now live in the "<db>.adr.db" sidecar, but a legacy
* pre-sidecar row can still linger in the graph DB and gets copied into the
* snapshot by VACUUM INTO; incremental/closure-repair clones keep carrying it.
* So ALWAYS clear project_summaries first, then insert only when an ADR is
* present (`adr` is NULL when the sidecar has none). Otherwise a no-ADR export
* would ship the stale row and a teammate's import would restore a decision
* record that was deleted. Best-effort: on any failure the artifact simply
* omits the ADR, as before the sidecar existed. */
static void snapshot_inject_adr(const char *snapshot_path, const cbm_adr_t *adr) {
sqlite3 *db = NULL;
if (sqlite3_open_v2(snapshot_path, &db, SQLITE_OPEN_READWRITE, NULL) != SQLITE_OK) {
sqlite3_close(db);
return;
}
sqlite3_exec(db,
"CREATE TABLE IF NOT EXISTS project_summaries ("
" project TEXT PRIMARY KEY, summary TEXT NOT NULL, source_hash TEXT NOT NULL,"
" created_at TEXT NOT NULL, updated_at TEXT NOT NULL);",
NULL, NULL, NULL);
/* Drop any stale row (a deleted ADR must not resurrect through the export). */
sqlite3_exec(db, "DELETE FROM project_summaries;", NULL, NULL, NULL);
if (adr && adr->project && adr->content) {
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(db,
"INSERT INTO project_summaries "
"(project, summary, source_hash, created_at, updated_at) "
"VALUES (?1, ?2, '', ?3, ?4);",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, adr->project, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 2, adr->content, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 3, adr->created_at ? adr->created_at : "", -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 4, adr->updated_at ? adr->updated_at : "", -1, SQLITE_TRANSIENT);
sqlite3_step(st);
sqlite3_finalize(st);
}
}
sqlite3_close(db);
}
/* Prepare a stripped DB copy for best-quality export.
* VACUUM INTO → (optionally) drop indexes → VACUUM. Returns malloc'd buffer
* or NULL. VACUUM INTO runs on BOTH quality levels: it is the consistent
* snapshot — the store runs in WAL mode, so raw main-file bytes miss
* committed transactions still in the -wal and can be mid-checkpoint torn
* (#895). Only the index-stripping is BEST-only. */
static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes) {
* VACUUM INTO → (optionally) drop indexes → VACUUM → (optionally) inject the
* ADR. Returns malloc'd buffer or NULL. VACUUM INTO runs on BOTH quality
* levels: it is the consistent snapshot — the store runs in WAL mode, so raw
* main-file bytes miss committed transactions still in the -wal and can be
* mid-checkpoint torn (#895). Only the index-stripping is BEST-only. */
static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes,
const cbm_adr_t *adr) {
artifact_snapshot_tmp_t tmp;
if (!artifact_snapshot_tmp_open(&tmp)) {
artifact_export_fail("prepare_snapshot_dir", cbm_tmpdir(), "private_tmpdir_failed", errno);
@@ -935,6 +976,11 @@ static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool str
}
}
/* Carry the ADR (from the sidecar) into the snapshot so the exported
* artifact ships the team's decision record. After the strip/VACUUM, so the
* row is not dropped. */
snapshot_inject_adr(tmp_path, adr);
/* Reopened by path rather than held on a descriptor across VACUUM INTO,
* because sqlite owns the create. The private directory is what makes that
* safe: an attacker who cannot enter it cannot swap the file underneath. */
@@ -980,15 +1026,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char *
char *db_data = NULL;
int compression_level = ART_ZSTD_FAST;
/* Fetch the ADR from the sidecar (read-only; does not create it) so the
* snapshot can carry it — the team-shared artifact used to include the ADR
* via project_summaries, and the sidecar must not silently break that. */
cbm_adr_t adr = {0};
bool have_adr = false;
cbm_store_t *adr_src = cbm_store_open_path_query(db_path);
if (adr_src) {
have_adr = cbm_store_adr_get(adr_src, project_name, &adr) == CBM_STORE_OK;
cbm_store_close(adr_src);
}
const cbm_adr_t *adr_arg = have_adr ? &adr : NULL;
if (quality == CBM_ARTIFACT_BEST) {
compression_level = ART_ZSTD_BEST;
db_data = prepare_snapshot_db(db_path, &db_size, true);
db_data = prepare_snapshot_db(db_path, &db_size, true, adr_arg);
} else {
/* FAST keeps zstd-3 and its indexes, but still snapshots via
* VACUUM INTO: the raw main-file bytes of a live WAL store are a
* torn copy (#895). */
db_data = prepare_snapshot_db(db_path, &db_size, false);
db_data = prepare_snapshot_db(db_path, &db_size, false, adr_arg);
}
cbm_store_adr_free(&adr);
if (!db_data || db_size == 0) {
free(db_data);
@@ -1071,6 +1130,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char *
/* ── Import ──────────────────────────────────────────────────────── */
/* Restore the imported ADR (carried in the graph DB's project_summaries) into
* the per-project sidecar so it arrives with the shared artifact — even for a
* teammate who already indexed the project (a marked sidecar with no ADR),
* which the marker-gated migrate-once would skip. INSERT OR IGNORE means a
* local ADR is never overwritten. Best-effort: the graph DB is already
* installed when this runs, so a failed restore never fails the import, but it
* is logged with the store's reason so a team ADR that did not arrive is
* diagnosable instead of silently missing. */
static void artifact_import_restore_adr(const char *cache_db_path) {
cbm_store_t *restore = cbm_store_open_path_existing(cache_db_path);
if (!restore) {
cbm_log_warn("artifact.import", "err", "adr_restore", "detail", "reopen_imported_db",
"path", cache_db_path);
return;
}
if (cbm_store_adr_restore_from_graph_if_absent(restore) != CBM_STORE_OK) {
cbm_log_warn("artifact.import", "err", "adr_restore", "detail", cbm_store_error(restore),
"path", cache_db_path);
}
cbm_store_close(restore);
}
int cbm_artifact_import(const char *repo_path, const char *cache_db_path) {
if (!repo_path || !cache_db_path) {
return CBM_NOT_FOUND;
@@ -1198,6 +1279,8 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) {
cbm_unlink(wal);
cbm_unlink(shm);
artifact_import_restore_adr(cache_db_path);
cbm_log_info("artifact.import", "db", cache_db_path, "size_mb",
itoa_buf((int)((size_t)dlen / ART_BYTES_PER_MB)));
+50 -3
View File
@@ -486,10 +486,12 @@ typedef struct {
const CBMLSPDef *all_defs;
const int *def_starts;
cbm_lsp_surface_row_t *rows;
int file_count;
_Atomic int next_file;
_Atomic bool failed;
} surface_job_t;
static void surface_row_one(int i, void *arg) {
static void surface_row_build(int i, void *arg) {
surface_job_t *job = (surface_job_t *)arg;
if (atomic_load_explicit(&job->failed, memory_order_relaxed)) {
return;
@@ -529,6 +531,45 @@ static void surface_row_one(int i, void *arg) {
}
}
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
static cbm_lsp_surface_row_observer_fn g_row_observer;
static void *g_row_observer_ctx;
void cbm_lsp_surface_test_set_row_observer(cbm_lsp_surface_row_observer_fn observer, void *ctx) {
g_row_observer = observer;
g_row_observer_ctx = ctx;
}
#endif
static void surface_row_one(int i, void *arg) {
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
if (g_row_observer) {
g_row_observer(i, true, g_row_observer_ctx);
}
#endif
surface_row_build(i, arg);
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
if (g_row_observer) {
g_row_observer(i, false, g_row_observer_ctx);
}
#endif
}
/* The pool also runs work on its caller. Dispatch at most one coarse job per
* allowed participant, each stealing files, so the total active row builders
* cannot exceed the pipeline budget even when the caller takes a job. */
static void surface_rows_worker(int worker, void *arg) {
(void)worker;
surface_job_t *job = arg;
for (;;) {
int i = atomic_fetch_add_explicit(&job->next_file, 1, memory_order_relaxed);
if (i >= job->file_count) {
break;
}
surface_row_one(i, job);
}
}
int cbm_lsp_surface_build_rows(const cbm_pipeline_ctx_t *ctx, const char *project,
CBMFileResult **cache, const cbm_file_info_t *files, int file_count,
const CBMLSPDef *all_defs, const int *def_starts,
@@ -550,10 +591,16 @@ int cbm_lsp_surface_build_rows(const cbm_pipeline_ctx_t *ctx, const char *projec
.all_defs = all_defs,
.def_starts = def_starts,
.rows = rows,
.file_count = file_count,
};
atomic_init(&job.next_file, 0);
atomic_init(&job.failed, false);
cbm_parallel_for(file_count, surface_row_one, &job,
(cbm_parallel_for_opts_t){.max_workers = 0, .force_pthreads = false});
int workers = cbm_pipeline_worker_count(ctx ? ctx->pipeline : NULL);
if (workers > file_count) {
workers = file_count;
}
cbm_parallel_for(workers, surface_rows_worker, &job,
(cbm_parallel_for_opts_t){.max_workers = workers, .force_pthreads = false});
if (atomic_load_explicit(&job.failed, memory_order_relaxed)) {
cbm_store_free_lsp_surfaces(rows, file_count); /* untouched rows are all NULL */
return -1;
+7
View File
@@ -68,4 +68,11 @@ int cbm_lsp_surface_python_namespace_from_json(CBMArena *arena, const char *json
*/
int cbm_lsp_surface_python_change(const char *old_json, const char *new_json);
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
/* Bracket each file callback, including skipped/error rows. Install only with
* no surface build running; clear after the observed build returns. */
typedef void (*cbm_lsp_surface_row_observer_fn)(int index, bool entering, void *ctx);
void cbm_lsp_surface_test_set_row_observer(cbm_lsp_surface_row_observer_fn observer, void *ctx);
#endif
#endif /* CBM_PIPELINE_LSP_SURFACE_H */
+8
View File
@@ -1361,6 +1361,14 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) {
return 0;
}
/* A bare Go `init(...)` calls a local closure or parameter: never a
* package init (unreferable in Go) and never a same-named method found
* by name alone — see cbm_go_suppress_bare_init_bind. MUST match
* pass_parallel.c exactly. */
if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name,
res.strategy, target_node->label)) {
return 0;
}
evidence->candidate_qn = target_node->qualified_name;
evidence->calls_emitted |=
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys,
+10
View File
@@ -720,6 +720,16 @@ static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *s
char method[CBM_SZ_32] = {0};
json_str_prop(props, "url_path", url_path, sizeof(url_path));
json_str_prop(props, "method", method, sizeof(method));
if (!method[0]) {
char callee[CBM_SZ_256] = {0};
json_str_prop(props, "callee", callee, sizeof(callee));
if (callee[0]) {
const char *m = cbm_service_pattern_http_method(callee);
if (m) {
snprintf(method, sizeof(method), "%s", m);
}
}
}
if (!url_path[0]) {
continue;
}
+68 -7
View File
@@ -2621,19 +2621,37 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
if (!cbm_service_pattern_is_http_route_literal(norm, call->callee_name)) {
continue;
}
const char *method = cbm_service_pattern_http_method(call->callee_name);
const char *prefix = method ? method : "ANY";
char route_qn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s",
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix,
cbm_route_canon_path(norm, cpath, sizeof(cpath)));
int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0,
"{\"source\":\"arg_url\"}");
char route_props[CBM_SZ_256];
if (method) {
snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\",\"method\":\"%s\"}",
method);
} else {
snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\"}");
}
int64_t route_id =
cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, route_props);
char esc_c[CBM_SZ_256];
char esc_n[CBM_SZ_256];
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
cbm_json_escape(esc_n, sizeof(esc_n), norm);
char eprops[CBM_SZ_512];
snprintf(eprops, sizeof(eprops),
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n);
/* Two escaped 255-byte strings plus the fixed keys and the verb: 1K
* never truncates the JSON (512 could, once the method was added). */
char eprops[CBM_SZ_1K];
if (method) {
snprintf(
eprops, sizeof(eprops),
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\",\"method\":\"%s\"}",
esc_c, esc_n, method);
} else {
snprintf(eprops, sizeof(eprops),
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n);
}
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "HTTP_CALLS", eprops);
break;
}
@@ -2985,6 +3003,29 @@ static const cbm_gbuf_node_t *find_source_node(const cbm_gbuf_t *gbuf, const cha
/* Field type hint resolution for obj.Method() with multiple candidates.
* Strips C# field prefixes (_ / m_), capitalizes to get type name, and
* checks if TypeName.Method or ITypeName.Method exists among candidates. */
/* #1927: the hint may only bind a candidate whose OWNING dot-segment — the
* segment immediately before the method name — EQUALS the hinted type. The
* previous raw strstr() accepted the hinted name anywhere in the QN, so a
* single-letter receiver (`f.Close()` → hint "F") matched nearly every
* candidate ("F" ⊂ "…FileStore.Close", the method name included) and rebound
* a call whose resolution had already failed to an arbitrary project symbol
* at PP_FIELD_HINT_CONF. Segment equality keeps the intended
* variable-named-after-its-type case (`repo.Find()` → `Repo.Find`,
* lrp_go_s8_field_type_hint) and picks the candidate whose owner actually
* carries the name instead of whichever QN contains the letters. */
static bool fth_owner_segment_is(const char *candidate_qn, const char *type_name) {
const char *last_dot = strrchr(candidate_qn, '.');
if (!last_dot || last_dot == candidate_qn) {
return false;
}
const char *seg_start = last_dot;
while (seg_start > candidate_qn && seg_start[-1] != '.') {
seg_start--;
}
size_t seg_len = (size_t)(last_dot - seg_start);
return seg_len > 0 && strncmp(seg_start, type_name, seg_len) == 0 && type_name[seg_len] == '\0';
}
static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const char *callee_name,
int64_t source_id) {
if (!res->qualified_name || res->candidate_count <= SKIP_ONE) {
@@ -3024,7 +3065,8 @@ static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const
int cand_count = 0;
cbm_registry_find_by_name(rc->registry, method, &cands, &cand_count);
for (int ci = 0; ci < cand_count; ci++) {
if (strstr(cands[ci], type_name) || strstr(cands[ci], iface_name)) {
if (fth_owner_segment_is(cands[ci], type_name) ||
fth_owner_segment_is(cands[ci], iface_name)) {
const cbm_gbuf_node_t *better = cbm_gbuf_find_by_qn(rc->main_gbuf, cands[ci]);
if (better && better->id != source_id) {
res->qualified_name = cands[ci];
@@ -3659,6 +3701,13 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
* Variable or a Field. */
continue;
}
if (target_node && source_node->id != target_node->id &&
cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name,
res.strategy, target_node->label)) {
/* Same guard as pass_calls.c — a bare Go `init` is a local name,
* never a package init or a method found by name alone. */
continue;
}
if (!target_node || source_node->id == target_node->id) {
/* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`)
* resolve to an unindexed QN (target_node == NULL), but their edge
@@ -3835,6 +3884,12 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws,
if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) {
continue;
}
/* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind).
* Mirrors the sequential twin (pass_usages.c). */
if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access,
usage->ref_name, res.strategy, tgt->label)) {
continue;
}
if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow ||
cbm_pipeline_node_is_callable_target(tgt))) {
continue;
@@ -3963,6 +4018,12 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi
if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) {
continue;
}
/* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind).
* Mirrors the sequential twin (pass_usages.c). */
if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name,
res.strategy, tgt->label)) {
continue;
}
const char *etype = rw->is_write ? "WRITES" : "READS";
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}");
}
+13
View File
@@ -288,6 +288,12 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res
if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) {
continue;
}
/* A bare Go `init` names a local, never a package init or an init
* method (cbm_go_suppress_bare_init_bind). Mirrors pass_parallel.c. */
if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access,
usage->ref_name, res.strategy, tgt->label)) {
continue;
}
if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow ||
cbm_pipeline_node_is_callable_target(tgt))) {
continue;
@@ -420,6 +426,13 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result
if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) {
continue;
}
/* `init := func(...)`: a bare Go `init` names a local, never a package
* init or an init method (cbm_go_suppress_bare_init_bind). Mirrors
* pass_parallel.c. */
if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name,
res.strategy, tgt->label)) {
continue;
}
const char *edge_type = rw->is_write ? "WRITES" : "READS";
cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}");
+100 -61
View File
@@ -81,11 +81,22 @@ static inline void *intptr_to_ptr(intptr_t v) {
* Atomic spinlock: 0 = free, 1 = locked. */
static atomic_int g_pipeline_busy = 0;
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
static atomic_int g_last_worker_count = 0;
void cbm_pipeline_worker_count_test_reset(void) {
atomic_store(&g_last_worker_count, 0);
}
int cbm_pipeline_worker_count_test_last(void) {
return atomic_load(&g_last_worker_count);
}
#endif
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
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;
@@ -103,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;
@@ -162,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;
@@ -210,6 +216,7 @@ struct cbm_pipeline {
bool persistence; /* write .codebase-memory/graph.db.zst after indexing */
cbm_index_resource_policy_t resource_policy;
cbm_index_resource_violation_t resource_violation;
bool background; /* reserve CPU headroom when no user is waiting */
/* Snapshot of the artifact export failure of THIS run (set only by
* export_after_publish failure, zeroed at run start, cleared on success).
@@ -277,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
@@ -965,6 +968,12 @@ const char *cbm_pipeline_export_error(const cbm_pipeline_t *p) {
return p ? p->export_error : "";
}
void cbm_pipeline_set_background(cbm_pipeline_t *p, bool background) {
if (p) {
p->background = background;
}
}
bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name) {
if (!p || !name || !name[0]) {
return false;
@@ -1472,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;
@@ -1829,17 +1835,42 @@ void cbm_pipeline_set_committed_counts(cbm_pipeline_t *p, int nodes, int edges)
}
}
/* Two workers is the smallest width that takes the parallel route. */
enum { PL_PARALLEL_MIN_WORKERS = 2 };
/* Background refreshes leave CPU headroom, but never drop below two workers
* while the foreground width is two or more. WHY: one worker sends the indexer
* down the sequential route, which still builds a different graph than the
* parallel route (fewer Route and HANDLES edges, churned CALLS). Until the two
* routes agree, an automatic refresh must not change the graph only because
* the host has two or fewer performance cores. */
int cbm_pipeline_background_worker_width(int foreground, int headroom) {
if (foreground >= PL_PARALLEL_MIN_WORKERS && headroom < PL_PARALLEL_MIN_WORKERS) {
return PL_PARALLEL_MIN_WORKERS;
}
return headroom;
}
/* Effective worker count. The crash supervisor re-runs its worker single-
* threaded (CBM_INDEX_SINGLE_THREAD=1) so a per-file marker can pin the EXACT
* crasher; a parallel re-run would race the marker. Honour that override
* everywhere the worker count drives the parallel/sequential decision, so the
* whole extraction phase collapses to the deterministic sequential path. */
static int effective_worker_count(bool initial) {
int cbm_pipeline_worker_count(const cbm_pipeline_t *p) {
const char *st = getenv("CBM_INDEX_SINGLE_THREAD");
int workers;
if (st && st[0] == '1') {
return 1;
workers = 1;
} else if (p && p->background) {
workers = cbm_pipeline_background_worker_width(cbm_default_worker_count(true),
cbm_default_worker_count(false));
} else {
workers = cbm_default_worker_count(true);
}
return cbm_default_worker_count(initial);
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
atomic_store(&g_last_worker_count, workers);
#endif
return workers;
}
/* Resolve the DB path for this pipeline. Caller must free(). */
@@ -3139,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);
@@ -3276,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");
@@ -3293,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);
@@ -3817,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. */
@@ -4108,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 =
@@ -4162,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;
@@ -4186,7 +4225,7 @@ static int run_githistory(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) {
gh_compute_arg_t gh_arg = {.repo_path = ctx->repo_path, .result = &gh_result};
if (p->mode != CBM_MODE_FAST) {
if (effective_worker_count(true) > SKIP_ONE) {
if (cbm_pipeline_worker_count(p) > SKIP_ONE) {
if (cbm_thread_create(&gh_thread, 0, gh_compute_thread_fn, &gh_arg) == 0) {
gh_threaded = true;
}
@@ -4286,7 +4325,7 @@ static int run_structure_and_extraction(cbm_pipeline_t *p, cbm_pipeline_ctx_t *c
return CBM_NOT_FOUND;
}
int worker_count = p->frozen ? 1 : effective_worker_count(true);
int worker_count = p->frozen ? 1 : cbm_pipeline_worker_count(p);
if (p->frozen)
frozen_observe(p, 0, 1);
CBM_PROF_START(t_extract_total);
@@ -4417,7 +4456,7 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) {
: mode_promoted
? cbm_pipeline_build_fresh_semantic_manifest(p, p->project_name, &baseline_manifest,
&baseline_count)
: cbm_pipeline_build_semantic_manifest(p->project_name, p->repo_path, files,
: cbm_pipeline_build_semantic_manifest(p, p->project_name, p->repo_path, files,
file_count, p->excluded_dirs, p->excluded_count,
&p->git_ctx, p->userconfig, &baseline_manifest,
&baseline_count);
+27
View File
@@ -71,6 +71,23 @@ bool cbm_pipeline_doc_candidates_enabled(const cbm_pipeline_t *p);
/* Copy the exact discovery violation from the most recent run. */
void cbm_pipeline_get_resource_violation(const cbm_pipeline_t *p,
cbm_index_resource_violation_t *violation);
/* Mark work that runs without a waiting user. Background pipelines reserve
* CPU headroom; foreground pipelines retain the initial-index all-core policy. */
void cbm_pipeline_set_background(cbm_pipeline_t *p, bool background);
/* Resolve the worker policy for this pipeline, including environment and
* crash-recovery overrides. */
int cbm_pipeline_worker_count(const cbm_pipeline_t *p);
/* Background width from the foreground width and the headroom width: the
* headroom, floored at two whenever the foreground width is two or more so an
* automatic refresh keeps the parallel route. */
int cbm_pipeline_background_worker_width(int foreground, int headroom);
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
void cbm_pipeline_worker_count_test_reset(void);
int cbm_pipeline_worker_count_test_last(void);
#endif
/* Snapshot of the artifact export failure of the last cbm_pipeline_run, or ""
* when the run succeeded / did not reach post-publish export. Used to
@@ -459,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
+25 -60
View File
@@ -479,9 +479,9 @@ static void *manifest_hash_worker(void *arg) {
enum { MANIFEST_PARALLEL_MIN_FILES = 64 };
int cbm_pipeline_build_semantic_manifest(const char *project, const char *repo_path,
const cbm_file_info_t *files, int file_count,
char **excluded_dirs, int excluded_count,
int cbm_pipeline_build_semantic_manifest(const cbm_pipeline_t *p, const char *project,
const char *repo_path, const cbm_file_info_t *files,
int file_count, char **excluded_dirs, int excluded_count,
const cbm_git_context_t *git_ctx,
const cbm_userconfig_t *userconfig, cbm_file_hash_t **out,
int *out_count) {
@@ -522,7 +522,7 @@ int cbm_pipeline_build_semantic_manifest(const char *project, const char *repo_p
}
struct timespec t_hash;
cbm_clock_gettime(CLOCK_MONOTONIC, &t_hash);
int hash_workers = cbm_default_worker_count(true);
int hash_workers = cbm_pipeline_worker_count(p);
if (rc == 0 && file_count >= MANIFEST_PARALLEL_MIN_FILES && hash_workers > SKIP_ONE) {
char (*shas)[CBM_SHA256_HEX_LEN + 1] = malloc((size_t)file_count * sizeof(*shas));
int64_t *mtimes = calloc((size_t)file_count, sizeof(int64_t));
@@ -869,9 +869,9 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr
&fresh_ignored_total);
}
if (rc == 0) {
rc = cbm_pipeline_build_semantic_manifest(project, repo_path, fresh_files, fresh_file_count,
fresh_excluded, fresh_excluded_count,
&fresh_git_ctx, fresh_userconfig, out, out_count);
rc = cbm_pipeline_build_semantic_manifest(
p, project, repo_path, fresh_files, fresh_file_count, fresh_excluded,
fresh_excluded_count, &fresh_git_ctx, fresh_userconfig, out, out_count);
}
cbm_set_user_lang_config(previous_userconfig);
cbm_git_context_free(&fresh_git_ctx);
@@ -1855,7 +1855,8 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed
* a full build takes, which is what makes its output converge. */
#define MIN_FILES_FOR_PARALLEL_INCR 50
int worker_count = cbm_pipeline_is_frozen(ctx->pipeline) ? 1 : cbm_default_worker_count(true);
int worker_count =
cbm_pipeline_is_frozen(ctx->pipeline) ? 1 : cbm_pipeline_worker_count(ctx->pipeline);
bool use_parallel =
closure != NULL || (worker_count > SKIP_ONE && ci > MIN_FILES_FOR_PARALLEL_INCR);
@@ -2238,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,
@@ -2253,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},
@@ -2341,7 +2340,7 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project,
_Atomic int64_t probe_ids;
atomic_init(&probe_ids, cbm_gbuf_next_id(probe_gbuf));
rc = cbm_parallel_extract(&probe_ctx, probe_files, probe_count, cache, &probe_ids,
cbm_default_worker_count(true));
cbm_pipeline_worker_count(p));
}
if (rc == 0) {
char **def_modules =
@@ -2355,10 +2354,14 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project,
if (def_modules && def_starts) {
defs = cbm_pxc_collect_all_defs(NULL, &probe_arena, cache, probe_files, probe_count,
project, def_modules, &def_count, def_starts);
rc = def_count < 0
? CBM_PIPELINE_ABORT_PRESERVE_DB
: cbm_lsp_surface_build_rows(NULL, project, cache, probe_files, probe_count,
defs, def_starts, out_rows, out_count);
/* Extract left pipeline NULL so file errors are not recorded twice.
* Surface rows only borrow it for the background worker policy. */
probe_ctx.pipeline = p;
rc = def_count < 0 ? CBM_PIPELINE_ABORT_PRESERVE_DB
: cbm_lsp_surface_build_rows(&probe_ctx, project, cache, probe_files,
probe_count, defs, def_starts, out_rows,
out_count);
probe_ctx.pipeline = NULL;
} else {
rc = -1;
}
@@ -3484,7 +3487,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
elig[elig_count++] = row;
}
}
int workers = cbm_default_worker_count(true);
int workers = cbm_pipeline_worker_count(p);
if (workers < 1) {
workers = 1;
}
@@ -3840,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 =
@@ -4233,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);
@@ -4429,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);
@@ -4551,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);
@@ -4589,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);
+3 -5
View File
@@ -1587,9 +1587,9 @@ bool cbm_pipeline_incremental_generation_exact(cbm_pipeline_t *p, cbm_store_t *s
#define CBM_SEMANTIC_INPUT_GLOBAL_CONFIG CBM_SEMANTIC_INPUT_PREFIX "global-extension-config-v1"
#define CBM_SEMANTIC_INPUT_PROJECT_CONFIG CBM_SEMANTIC_INPUT_PREFIX "project-extension-config-v1"
int cbm_pipeline_build_semantic_manifest(const char *project, const char *repo_path,
const cbm_file_info_t *files, int file_count,
char **excluded_dirs, int excluded_count,
int cbm_pipeline_build_semantic_manifest(const cbm_pipeline_t *p, const char *project,
const char *repo_path, const cbm_file_info_t *files,
int file_count, char **excluded_dirs, int excluded_count,
const cbm_git_context_t *git_ctx,
const cbm_userconfig_t *userconfig, cbm_file_hash_t **out,
int *out_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);
+40
View File
@@ -1015,6 +1015,46 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s
return confidence < CONF_UNIQUE_NAME;
}
bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name,
const char *strategy, const char *target_label) {
/* The Go spec gives `init` no binding in the package block: the
* identifier declares init functions and nothing else, and "init
* functions cannot be referred to from anywhere in a program". A method
* or a field named init is reached only through a selector (`x.init()`,
* `e.init`), which arrives here as is_member and is left alone. So a
* BARE `init` is always a local name: a closure (`init :=
* func(ctx) {...}; init(ctx)`, 22 kubernetes e2e storage suites), a
* parameter (`func sum(init func() ref.Val)`), or the `package init`
* clause of a directory named init. None of them is a project node.
*
* Every Go Function named init is a package init (no other package-level
* `init` can be declared), so that target is refused for every registry
* strategy. Before #1915 the flat QN made same_module bind the bare name
* to its own package's init; after it, the per-file QN misses there and
* the name fallback binds unrelated init methods instead. A Method or
* Field namesake is refused when only the name found it (the shared weak
* drop-list), which is how every such edge was minted. lsp_* answers are
* untouched: the sequential resolver emits them before its registry
* guards run, so refusing one here would split the two resolvers.
*
* Go-gated: elsewhere `init` is an ordinary name. A bare `init()` in a
* Java, C#, C++, Kotlin or Ruby method calls the class's own method, and
* a C, Python or JS `init` function is called by its bare name. */
if (!is_go || is_member || !name || strcmp(name, "init") != 0 || !target_label) {
return false;
}
if (strategy && strncmp(strategy, "lsp_", strlen("lsp_")) == 0) {
return false;
}
if (strcmp(target_label, "Function") == 0) {
return true;
}
if (strcmp(target_label, "Method") == 0 || strcmp(target_label, "Field") == 0) {
return weak_short_name_strategy(strategy);
}
return false;
}
/* Import-binding counterpart of the parameter guard above (#2127). An import
* binds the identifier for the whole module; when that import is external it
* never materializes, and the chain falls through to project-wide short-name
+21
View File
@@ -22,9 +22,28 @@ enum { WP_TRUE = 1, WP_MIN = 1, WP_STEP = 1 };
/* CBM_WORKER_STACK_SIZE (8 MB per worker) lives in worker_pool.h. */
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
static cbm_parallel_spawn_observer_fn g_spawn_observer;
static void *g_spawn_observer_ctx;
void cbm_parallel_test_set_spawn_observer(cbm_parallel_spawn_observer_fn observer, void *ctx) {
g_spawn_observer = observer;
g_spawn_observer_ctx = ctx;
}
static void observe_spawn(int count, int helpers) {
if (g_spawn_observer) {
g_spawn_observer(count, helpers, g_spawn_observer_ctx);
}
}
#else
#define observe_spawn(count, helpers) ((void)0)
#endif
/* ── Serial fallback ─────────────────────────────────────────────── */
static void run_serial(int count, cbm_parallel_fn fn, void *ctx) {
observe_spawn(count, 0);
for (int i = 0; i < count; i++) {
fn(i, ctx);
}
@@ -103,6 +122,7 @@ static void run_pthreads_counted(int count, cbm_parallel_fn fn, void *ctx, int n
}
started++;
}
observe_spawn(count, started);
int main_done = 0;
uint64_t main_ops_before = cbm_memev_thread_ops();
while (WP_TRUE) {
@@ -164,6 +184,7 @@ static void run_pthreads(int count, cbm_parallel_fn fn, void *ctx, int nworkers)
}
}
observe_spawn(count, nworkers);
/* Main thread also participates */
while (WP_TRUE) {
int idx = atomic_fetch_add_explicit(&next_idx, WP_STEP, memory_order_relaxed);
+7
View File
@@ -36,4 +36,11 @@ typedef struct {
* If count <= 1 or workers <= 1, runs single-threaded. */
void cbm_parallel_for(int count, cbm_parallel_fn fn, void *ctx, cbm_parallel_for_opts_t opts);
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
/* Observe the completed spawn attempt before the caller takes work. Install
* only while no dispatch is running; clear after the observed dispatch joins. */
typedef void (*cbm_parallel_spawn_observer_fn)(int count, int helpers, void *ctx);
void cbm_parallel_test_set_spawn_observer(cbm_parallel_spawn_observer_fn observer, void *ctx);
#endif
#endif /* CBM_WORKER_POOL_H */
+363 -42
View File
@@ -183,6 +183,15 @@ struct cbm_store {
sqlite3_stmt *stmt_get_file_hashes;
sqlite3_stmt *stmt_delete_file_hash;
sqlite3_stmt *stmt_delete_file_hashes;
/* ADRs live in a per-project sidecar DB ("<db_path>.adr.db"), NOT in the
* graph DB, so a reindex that rebuilds/replaces the graph DB never touches
* them (removing the capture/re-apply race entirely). The read-write handle
* is lazily opened on the first ADR write/migration and cached for the
* store's lifetime; NULL for a :memory: store, where ADRs stay in the graph
* DB (never file-swapped, no race). Reads open a short-lived read-only
* handle so a query-only cache is never mutated. */
sqlite3 *adr_db;
};
/* ── Helpers ────────────────────────────────────────────────────── */
@@ -195,6 +204,13 @@ static void store_set_error_sqlite(cbm_store_t *s, const char *prefix) {
snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(s->db));
}
/* Like store_set_error_sqlite but reads the message from an explicit
* connection — used for the ADR sidecar DB, whose errors do not live on
* s->db. */
static void store_set_error_sqlite_on(cbm_store_t *s, sqlite3 *db, const char *prefix) {
snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(db));
}
static int exec_sql(cbm_store_t *s, const char *sql) {
if (!s || !s->db) {
return CBM_STORE_ERR;
@@ -1530,6 +1546,11 @@ void cbm_store_close(cbm_store_t *s) {
* Prevents ASan false-positive leaks from sqlite3 internal state. */
sqlite3_close_v2(s->db);
cbm_graph_mem_release(s->graph_mem);
/* ADR sidecar: checkpoint + close its own connection (file stores only). */
if (s->adr_db) {
(void)sqlite3_wal_checkpoint_v2(s->adr_db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL);
sqlite3_close_v2(s->adr_db);
}
safe_str_free(&s->db_path);
free(s);
}
@@ -11923,7 +11944,260 @@ void cbm_adr_sections_free(cbm_adr_sections_t *s) {
memset(s, 0, sizeof(*s));
}
/* ── ADR sidecar store ──────────────────────────────────────────────
* ADRs are kept in a per-project sidecar SQLite DB ("<graph_db>.adr.db"), so a
* reindex that deletes/replaces the graph DB never disturbs them (no capture,
* re-read or re-apply, no lock). adr_conn() returns the connection ADR ops run
* on: the sidecar for a file-backed store (opened, schema-created and migrated
* once, then cached), or the graph DB for a :memory: store (never file-swapped,
* so there is no race to avoid there). */
/* The sidecar's on-disk path ("<db_path>.adr.db"), routed through
* cbm_path_for_file_api so a long path gets the Windows \\?\ prefix (like
* store_open_internal). Returns false for a :memory: store or an over-long
* path. `raw` receives the plain path for existence checks/unlinks; `api`
* receives the file-API form used to open. */
static bool adr_sidecar_path(const cbm_store_t *s, char *raw, size_t raw_sz, char *api,
size_t api_sz) {
if (!s->db_path) {
return false;
}
int n = snprintf(raw, raw_sz, "%s.adr.db", s->db_path);
if (n <= 0 || (size_t)n >= raw_sz) {
return false;
}
return cbm_path_for_file_api(raw, api, api_sz);
}
/* Match the graph store's locking so cross-process ADR access waits rather than
* failing: WAL + a 10 s busy timeout (SQLite's own lock wait, not a hand-rolled
* poll). Best-effort: a read-only sidecar rejects journal_mode, which is fine. */
static void adr_configure(sqlite3 *db) {
(void)sqlite3_exec(db, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL);
(void)sqlite3_exec(db, "PRAGMA journal_mode = WAL;", NULL, NULL, NULL);
}
static int adr_ensure_schema(sqlite3 *db) {
/* project_summaries has the same shape as the graph DB's, so migration is a
* plain row copy and the cbm_adr_t mapping is unchanged. adr_meta carries a
* one-time "migrated" marker so the legacy copy runs exactly once and can
* never resurrect a row deleted after migration. */
int rc = sqlite3_exec(db,
"CREATE TABLE IF NOT EXISTS project_summaries ("
" project TEXT PRIMARY KEY,"
" summary TEXT NOT NULL,"
" source_hash TEXT NOT NULL,"
" created_at TEXT NOT NULL,"
" updated_at TEXT NOT NULL"
");"
"CREATE TABLE IF NOT EXISTS adr_meta ("
" key TEXT PRIMARY KEY,"
" value TEXT NOT NULL"
");",
NULL, NULL, NULL);
return (rc == SQLITE_OK) ? CBM_STORE_OK : CBM_STORE_ERR;
}
/* True once the one-time legacy migration has been recorded in the sidecar. */
static bool adr_migrated(sqlite3 *adb) {
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(adb, "SELECT 1 FROM adr_meta WHERE key='migrated' LIMIT 1;",
CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) {
return false;
}
bool done = sqlite3_step(st) == SQLITE_ROW;
sqlite3_finalize(st);
return done;
}
/* Copy every ADR row from the graph DB's project_summaries into the sidecar with
* INSERT OR IGNORE (so a row already present locally is never overwritten). Must
* run inside a transaction on the sidecar connection. Returns OK/ERR; does not
* touch the `migrated` marker. */
static int adr_copy_rows(cbm_store_t *s, sqlite3 *adb) {
sqlite3_stmt *probe = NULL;
if (sqlite3_prepare_v2(
s->db,
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;",
CBM_NOT_FOUND, &probe, NULL) != SQLITE_OK) {
return CBM_STORE_ERR;
}
bool has_legacy = sqlite3_step(probe) == SQLITE_ROW;
sqlite3_finalize(probe);
if (!has_legacy) {
return CBM_STORE_OK; /* no source table -> nothing to copy */
}
sqlite3_stmt *sel = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT project, summary, source_hash, created_at, updated_at "
"FROM project_summaries;",
CBM_NOT_FOUND, &sel, NULL) != SQLITE_OK) {
return CBM_STORE_ERR;
}
sqlite3_stmt *ins = NULL;
if (sqlite3_prepare_v2(adb,
"INSERT OR IGNORE INTO project_summaries "
"(project, summary, source_hash, created_at, updated_at) "
"VALUES (?1, ?2, ?3, ?4, ?5);",
CBM_NOT_FOUND, &ins, NULL) != SQLITE_OK) {
sqlite3_finalize(sel);
return CBM_STORE_ERR;
}
int rc = SQLITE_DONE;
while ((rc = sqlite3_step(sel)) == SQLITE_ROW) {
for (int c = 0; c < ST_COL_5; c++) {
bind_text(ins, c + 1, (const char *)sqlite3_column_text(sel, c));
}
int irc = sqlite3_step(ins);
sqlite3_reset(ins);
sqlite3_clear_bindings(ins);
if (irc != SQLITE_DONE) {
sqlite3_finalize(sel);
sqlite3_finalize(ins);
return CBM_STORE_ERR;
}
}
sqlite3_finalize(sel);
sqlite3_finalize(ins);
return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR;
}
/* Copy any legacy ADR rows from the graph DB's project_summaries into the
* sidecar (INSERT OR IGNORE, so a local row is never clobbered), then set the
* one-time marker. The marker check, copy and marker insert run in ONE
* BEGIN IMMEDIATE transaction on the sidecar, so two processes migrating at
* once cannot re-copy a row the other just deleted. Returns CBM_STORE_ERR on
* any failure (transaction rolled back) so callers can fail closed and preserve
* the old generation. No-op once the marker is set. */
static int adr_copy_legacy_once(cbm_store_t *s, sqlite3 *adb) {
if (adr_migrated(adb)) {
return CBM_STORE_OK; /* fast path: already migrated, no lock needed */
}
if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) {
return CBM_STORE_ERR;
}
/* Re-check under the write lock: another process may have migrated between
* the fast-path check and acquiring the lock. */
if (adr_migrated(adb)) {
(void)sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL);
return CBM_STORE_OK;
}
if (adr_copy_rows(s, adb) != CBM_STORE_OK) {
goto fail;
}
if (sqlite3_exec(adb, "INSERT OR IGNORE INTO adr_meta (key, value) VALUES ('migrated','1');",
NULL, NULL, NULL) != SQLITE_OK) {
goto fail;
}
if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) {
goto fail;
}
return CBM_STORE_OK;
fail:
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
return CBM_STORE_ERR;
}
/* Restore ADRs from the graph DB into the sidecar for any project the sidecar has
* no row for, WITHOUT overwriting a local one, regardless of the `migrated`
* marker. This is what import needs: an artifact carries its ADR in the graph
* DB's project_summaries, and a teammate who already indexed the project (so the
* sidecar is marked, but has no ADR) must still receive it. INSERT OR IGNORE
* guarantees a local ADR is never clobbered; running ungated by the marker is
* what distinguishes this from the one-time legacy migration. It never writes
* the marker: `adb` comes from adr_conn_write, which hands out a sidecar handle
* only after adr_copy_legacy_once committed it, and nothing deletes it. On
* failure the store error names the step (the import logs it). */
static int adr_restore_if_absent(cbm_store_t *s, sqlite3 *adb) {
if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) {
store_set_error_sqlite_on(s, adb, "adr restore begin");
return CBM_STORE_ERR;
}
if (adr_copy_rows(s, adb) != CBM_STORE_OK) {
store_set_error(s, "adr restore copy failed");
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
return CBM_STORE_ERR;
}
if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) {
store_set_error_sqlite_on(s, adb, "adr restore commit");
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
/* Open (create) the read-write sidecar, configure locking, ensure the schema,
* run the one-time legacy migration, and cache the handle. NULL + error set on
* failure (fail closed). :memory: stores return the graph DB (no race there). */
static sqlite3 *adr_conn_write(cbm_store_t *s) {
if (!s || !s->db) {
return NULL;
}
if (!s->db_path) {
return s->db; /* :memory: store — ADRs stay in the graph DB */
}
if (s->adr_db) {
return s->adr_db;
}
char raw[4096];
char api[4096];
if (!adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api))) {
store_set_error(s, "adr sidecar path too long");
return NULL;
}
sqlite3 *adb = NULL;
int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
if (sqlite3_open_v2(api, &adb, flags, NULL) != SQLITE_OK) {
store_set_error_sqlite_on(s, adb, "adr sidecar open");
sqlite3_close_v2(adb);
return NULL;
}
adr_configure(adb);
if (adr_ensure_schema(adb) != CBM_STORE_OK) {
store_set_error_sqlite_on(s, adb, "adr sidecar schema");
sqlite3_close_v2(adb);
return NULL;
}
if (adr_copy_legacy_once(s, adb) != CBM_STORE_OK) {
store_set_error(s, "adr sidecar legacy migration failed");
sqlite3_close_v2(adb);
return NULL;
}
s->adr_db = adb;
return s->adr_db;
}
int cbm_store_adr_migrate_once(cbm_store_t *s) {
if (!s || !s->db) {
return CBM_STORE_ERR;
}
if (!s->db_path) {
return CBM_STORE_OK; /* :memory: — ADRs already in the graph DB, nothing to move */
}
return adr_conn_write(s) ? CBM_STORE_OK : CBM_STORE_ERR;
}
int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s) {
if (!s || !s->db) {
return CBM_STORE_ERR;
}
if (!s->db_path) {
return CBM_STORE_OK; /* :memory: — ADR already lives in the graph DB */
}
sqlite3 *adb = adr_conn_write(s);
if (!adb) {
return CBM_STORE_ERR;
}
return adr_restore_if_absent(s, adb);
}
int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content) {
sqlite3 *adb = adr_conn_write(s);
if (!adb) {
return CBM_STORE_ERR;
}
char now[CBM_SZ_32];
iso_now(now, sizeof(now));
@@ -11933,8 +12207,8 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content
"ON CONFLICT(project) DO UPDATE SET summary=excluded.summary, "
"updated_at=excluded.updated_at";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_store");
if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite_on(s, adb, "adr_store");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
@@ -11946,22 +12220,17 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content
return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR;
}
int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
if (!s || !s->db || !project || !out) {
return CBM_STORE_ERR;
}
memset(out, 0, sizeof(*out));
/* ADR storage was added after the original graph schema. A readable
* legacy generation without project_summaries has no ADR to preserve; it
* is not a read failure and must remain replaceable by a full reindex. */
/* Read one ADR row from an already-open connection into `out`. Returns
* CBM_STORE_OK / CBM_STORE_NOT_FOUND / CBM_STORE_ERR. Never writes. */
static int adr_read_row(cbm_store_t *s, sqlite3 *adb, const char *project, cbm_adr_t *out) {
/* A readable legacy generation without project_summaries has no ADR; that is
* NOT_FOUND, not a read failure. */
sqlite3_stmt *table_stmt = NULL;
int rc = sqlite3_prepare_v2(
s->db,
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;",
adb, "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;",
CBM_NOT_FOUND, &table_stmt, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "adr_get table probe");
store_set_error_sqlite_on(s, adb, "adr_get table probe");
return CBM_STORE_ERR;
}
rc = sqlite3_step(table_stmt);
@@ -11970,26 +12239,26 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
return CBM_STORE_NOT_FOUND;
}
if (rc != SQLITE_ROW || sqlite3_finalize(table_stmt) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_get table probe step");
store_set_error_sqlite_on(s, adb, "adr_get table probe step");
return CBM_STORE_ERR;
}
const char *sql = "SELECT project, summary, created_at, updated_at FROM project_summaries "
"WHERE project=?1";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_get");
if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite_on(s, adb, "adr_get");
return CBM_STORE_ERR;
}
if (bind_text(stmt, SKIP_ONE, project) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_get bind");
store_set_error_sqlite_on(s, adb, "adr_get bind");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
rc = sqlite3_step(stmt);
if (rc != SQLITE_ROW) {
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "adr_get step");
store_set_error_sqlite_on(s, adb, "adr_get step");
}
sqlite3_finalize(stmt);
if (rc == SQLITE_DONE) {
@@ -12005,13 +12274,11 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
rc = sqlite3_finalize(stmt);
if (rc != SQLITE_OK) {
cbm_store_adr_free(out);
store_set_error_sqlite(s, "adr_get finalize");
store_set_error_sqlite_on(s, adb, "adr_get finalize");
return CBM_STORE_ERR;
}
/* Every selected column is NOT NULL in the schema. A NULL here therefore
* means either allocation failure or corrupt data; never return a partial
* ADR that a full rebuild could silently drop during publication. */
/* Every selected column is NOT NULL in the schema. A NULL here means an
* allocation failure or corruption; never return a partial ADR. */
if (!out->project || !out->content || !out->created_at || !out->updated_at) {
cbm_store_adr_free(out);
store_set_error(s, "adr_get: failed to copy complete ADR");
@@ -12020,16 +12287,62 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
return CBM_STORE_OK;
}
int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
if (!s || !s->db || !project || !out) {
return CBM_STORE_ERR;
}
memset(out, 0, sizeof(*out));
/* :memory: store, or a sidecar already open read-write: read it directly. */
if (!s->db_path) {
return adr_read_row(s, s->db, project, out);
}
if (s->adr_db) {
return adr_read_row(s, s->adr_db, project, out);
}
/* A read must never create the sidecar (a query-only cache may be
* read-only): open it READONLY if the file exists, otherwise read the
* legacy row from the graph DB without migrating. */
char raw[4096];
char api[4096];
if (adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api)) && cbm_file_exists(raw)) {
sqlite3 *ro = NULL;
if (sqlite3_open_v2(api, &ro, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) {
store_set_error_sqlite_on(s, ro, "adr sidecar open (ro)");
sqlite3_close_v2(ro);
return CBM_STORE_ERR;
}
(void)sqlite3_exec(ro, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL);
/* A sidecar that exists but was never marked migrated is half-created:
* the legacy copy did not commit, so the ADR (if any) still lives only
* in the graph DB. Fall back to the graph row so a partial sidecar never
* hides an existing ADR. */
if (!adr_migrated(ro)) {
sqlite3_close_v2(ro);
return adr_read_row(s, s->db, project, out);
}
int rc = adr_read_row(s, ro, project, out);
sqlite3_close_v2(ro);
return rc;
}
return adr_read_row(s, s->db, project, out);
}
int cbm_store_adr_delete(cbm_store_t *s, const char *project) {
sqlite3 *adb = adr_conn_write(s);
if (!adb) {
return CBM_STORE_ERR;
}
const char *sql = "DELETE FROM project_summaries WHERE project=?1";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_delete");
if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite_on(s, adb, "adr_delete");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
int rc = sqlite3_step(stmt);
int changes = sqlite3_changes(s->db);
int changes = sqlite3_changes(adb);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
return CBM_STORE_ERR;
@@ -12047,13 +12360,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
return CBM_STORE_ERR;
}
/* The read-modify-write below must be ONE transaction. Three writers
* replace this row wholesale — the indexing pipeline, the UI POST
* /api/adr handler, and manage_adr mode='update' — so an unguarded
* get/merge/store silently loses whichever of them commits between the
* read and the UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a
* competing writer waits rather than being overwritten. */
if (cbm_store_begin(s) != CBM_STORE_OK) {
/* The read-modify-write below must be ONE transaction, and it runs on the
* ADR connection (the sidecar for a file store, the graph DB for :memory:)
* — the same connection cbm_store_adr_get/store use, so the write lock
* actually covers them. Two writers replace this row wholesale — the UI
* POST /api/adr handler and manage_adr mode='update' — so an unguarded
* get/merge/store silently loses whichever commits between the read and the
* UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a competing
* writer waits rather than being overwritten. */
sqlite3 *adb = adr_conn_write(s);
if (!adb) {
return CBM_STORE_ERR;
}
if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) {
store_set_error_sqlite_on(s, adb, "adr_update begin");
return CBM_STORE_ERR;
}
@@ -12061,7 +12381,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
cbm_adr_t existing;
int rc = cbm_store_adr_get(s, project, &existing);
if (rc != CBM_STORE_OK) {
(void)cbm_store_rollback(s);
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
store_set_error(s, "no existing ADR to update");
return rc;
}
@@ -12076,7 +12396,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
if (cbm_adr_check_structure(existing.content, structure_err, (int)sizeof(structure_err)) !=
CBM_STORE_OK) {
cbm_store_adr_free(&existing);
(void)cbm_store_rollback(s);
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
store_set_error(s, structure_err);
return CBM_STORE_ERR;
}
@@ -12090,7 +12410,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
}
if (!merged) {
(void)cbm_store_rollback(s);
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
store_set_error(s, "failed to splice ADR section");
return CBM_STORE_ERR;
}
@@ -12101,7 +12421,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
snprintf(msg, sizeof(msg), "merged ADR exceeds %d chars (%d chars)", CBM_ADR_MAX_LENGTH,
(int)strlen(merged));
free(merged);
(void)cbm_store_rollback(s);
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
store_set_error(s, msg);
return CBM_STORE_ERR;
}
@@ -12110,19 +12430,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
rc = cbm_store_adr_store(s, project, merged);
free(merged);
if (rc != CBM_STORE_OK) {
(void)cbm_store_rollback(s);
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
return rc;
}
/* Read back INSIDE the transaction so `out` is exactly what commits. */
rc = cbm_store_adr_get(s, project, out);
if (rc != CBM_STORE_OK) {
(void)cbm_store_rollback(s);
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
return rc;
}
if (cbm_store_commit(s) != CBM_STORE_OK) {
if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) {
store_set_error_sqlite_on(s, adb, "adr_update commit");
cbm_store_adr_free(out);
(void)cbm_store_rollback(s);
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
+11
View File
@@ -1163,6 +1163,17 @@ typedef struct {
int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content);
int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out);
/* Move any legacy ADR row from the graph DB's project_summaries into the
* per-project sidecar, once (guarded by a marker in the sidecar). Call before a
* rebuild deletes the old generation, and on artifact import. Returns
* CBM_STORE_ERR on failure so callers can preserve the old generation. */
int cbm_store_adr_migrate_once(cbm_store_t *s);
/* Restore ADRs from the graph DB's project_summaries into the per-project sidecar
* for any project the sidecar has no row for, without overwriting a local one and
* regardless of the migrated marker. Used on artifact import so a teammate who
* already indexed the project (marked sidecar, no ADR) still receives the shared
* ADR the artifact carries. Returns CBM_STORE_ERR on failure. */
int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s);
int cbm_store_adr_delete(cbm_store_t *s, const char *project);
int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const char **keys,
const char **values, int count, cbm_adr_t *out);
+241
View File
@@ -2,6 +2,7 @@
* test_artifact.c — Tests for persistent artifact export/import.
*/
#include "test_framework.h"
#include "sqlite3.h" /* vendored/sqlite3 — seed a legacy project_summaries row in ADR tests */
#include "store/store.h"
#include "pipeline/artifact.h"
#include "pipeline/pipeline.h"
@@ -64,6 +65,21 @@ static void cleanup_dir(const char *path) {
(void)system(cmd);
}
/* Remove a destination graph DB (+ its -wal/-shm/-journal) while leaving the ADR
* sidecar "<db>.adr.db" in place. Import only runs when the graph DB is missing
* (try_artifact_bootstrap); this mirrors that shape so the import rename does not
* hit an existing file (which Windows refuses, unlike POSIX). */
static void remove_graph_db_keep_sidecar(const char *db) {
char p[1200];
(void)cbm_unlink(db);
snprintf(p, sizeof(p), "%s-wal", db);
(void)cbm_unlink(p);
snprintf(p, sizeof(p), "%s-shm", db);
(void)cbm_unlink(p);
snprintf(p, sizeof(p), "%s-journal", db);
(void)cbm_unlink(p);
}
static void write_text_file(const char *path, const char *text) {
FILE *fp = fopen(path, "w");
if (!fp) {
@@ -206,6 +222,226 @@ TEST(artifact_export_fast_roundtrip) {
PASS();
}
/* The ADR travels with the exported artifact: it lives in the "<db>.adr.db"
* sidecar, and export must carry it into graph.db.zst so an import restores it
* into the destination's sidecar (round-2 review item 7d). */
TEST(artifact_export_roundtrip_keeps_adr) {
setup_artifact_test();
create_test_db(g_db);
const char *adr_text = "# Decision\nThe team chose the sidecar design.";
cbm_store_t *src = cbm_store_open_path(g_db);
ASSERT_NOT_NULL(src);
ASSERT_EQ(cbm_store_adr_store(src, "test-proj", adr_text), CBM_STORE_OK);
cbm_store_close(src);
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
char import_db[1024];
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0);
/* The imported project starts with the team's ADR (restored into the
* destination sidecar by import). */
cbm_store_t *dst = cbm_store_open_path(import_db);
ASSERT_NOT_NULL(dst);
cbm_adr_t adr = {0};
ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK);
ASSERT_NOT_NULL(adr.content);
ASSERT_STR_EQ(adr.content, adr_text);
cbm_store_adr_free(&adr);
cbm_store_close(dst);
/* The fresh destination sidecar is marked migrated after the import, so a
* later reindex's migrate-before-delete is a no-op. Opening the sidecar for
* the restore already commits the marker; the restore itself adds none. */
char side[1100];
snprintf(side, sizeof(side), "%s.adr.db", import_db);
sqlite3 *raw = NULL;
ASSERT_EQ(sqlite3_open_v2(side, &raw, SQLITE_OPEN_READONLY, NULL), SQLITE_OK);
sqlite3_stmt *st = NULL;
ASSERT_EQ(
sqlite3_prepare_v2(raw, "SELECT value FROM adr_meta WHERE key='migrated';", -1, &st, NULL),
SQLITE_OK);
ASSERT_EQ(sqlite3_step(st), SQLITE_ROW);
sqlite3_finalize(st);
sqlite3_close(raw);
cleanup_dir(g_tmpdir);
PASS();
}
/* Regression: a stale legacy `project_summaries` row that lingers in the graph DB
* after its ADR was migrated then DELETED must NOT resurrect through an export.
* With the sidecar empty, the snapshot must ship an empty table, so an import
* finds no ADR. (Before the DELETE-first fix, VACUUM INTO copied the stale row
* and import restored a deleted decision record.) */
TEST(artifact_export_without_adr_does_not_resurrect_legacy_row) {
setup_artifact_test();
create_test_db(g_db);
/* Pre-sidecar layout: an ADR lingering only in the graph DB. */
sqlite3 *raw = NULL;
ASSERT_EQ(sqlite3_open(g_db, &raw), SQLITE_OK);
char *sql = sqlite3_mprintf(
"INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) "
"VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');",
"test-proj", "# Decision\nstale, later deleted");
ASSERT_NOT_NULL(sql);
ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK);
sqlite3_free(sql);
sqlite3_close(raw);
/* Migrate it into the sidecar (marker set), then DELETE it. The graph row
* still lingers; the sidecar is now migrated-but-empty. */
cbm_store_t *s = cbm_store_open_path(g_db);
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_adr_migrate_once(s), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_delete(s, "test-proj"), CBM_STORE_OK);
cbm_store_close(s);
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
char import_db[1024];
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0);
cbm_store_t *dst = cbm_store_open_path(import_db);
ASSERT_NOT_NULL(dst);
cbm_adr_t adr = {0};
ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_NOT_FOUND);
cbm_store_adr_free(&adr);
cbm_store_close(dst);
cleanup_dir(g_tmpdir);
PASS();
}
/* An import must not clobber an ADR already present locally: the restore uses
* INSERT OR IGNORE, so the teammate's own decision record survives importing a
* shared artifact. */
TEST(artifact_import_does_not_clobber_local_adr) {
setup_artifact_test();
create_test_db(g_db);
/* The shared artifact carries ADR "A". */
cbm_store_t *src = cbm_store_open_path(g_db);
ASSERT_NOT_NULL(src);
ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK);
cbm_store_close(src);
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
/* The importing machine already has its own local ADR "B" for this project. */
char import_db[1024];
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
cbm_store_t *local = cbm_store_open_path(import_db);
ASSERT_NOT_NULL(local);
ASSERT_EQ(cbm_store_adr_store(local, "test-proj", "# Decision\nlocal B"), CBM_STORE_OK);
cbm_store_close(local);
/* Import only runs when the graph DB is missing (try_artifact_bootstrap), and
* Windows cannot rename over an existing file. Remove the destination graph DB
* (keeping the sidecar + its marker) to match production. */
remove_graph_db_keep_sidecar(import_db);
ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0);
/* Local B is preserved — the import did not overwrite it with shared A. */
cbm_store_t *dst = cbm_store_open_path(import_db);
ASSERT_NOT_NULL(dst);
cbm_adr_t adr = {0};
ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK);
ASSERT_NOT_NULL(adr.content);
ASSERT_STR_EQ(adr.content, "# Decision\nlocal B");
cbm_store_adr_free(&adr);
cbm_store_close(dst);
cleanup_dir(g_tmpdir);
PASS();
}
/* The shared ADR must reach a teammate who has ALREADY indexed the project: their
* sidecar is marked migrated but has no ADR row, so the marker-gated migrate-once
* would skip it. Import restores-if-absent (ungated), so the artifact's ADR "A"
* arrives. (On main, import replaced the whole DB, so the ADR always came through;
* the sidecar must preserve that.) */
TEST(artifact_import_restores_adr_when_local_absent) {
setup_artifact_test();
create_test_db(g_db);
/* The shared artifact carries ADR "A". */
cbm_store_t *src = cbm_store_open_path(g_db);
ASSERT_NOT_NULL(src);
ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK);
cbm_store_close(src);
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
/* The destination is already indexed: a marked sidecar with NO ADR row. */
char import_db[1024];
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
create_test_db(import_db);
cbm_store_t *pre = cbm_store_open_path(import_db);
ASSERT_NOT_NULL(pre);
ASSERT_EQ(cbm_store_adr_migrate_once(pre), CBM_STORE_OK); /* marks the sidecar, no ADR */
cbm_adr_t none = {0};
ASSERT_EQ(cbm_store_adr_get(pre, "test-proj", &none), CBM_STORE_NOT_FOUND);
cbm_store_adr_free(&none);
cbm_store_close(pre);
/* Import only runs when the graph DB is missing; remove it (keep the marked
* sidecar) so the rename succeeds on Windows too. */
remove_graph_db_keep_sidecar(import_db);
ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0);
/* The shared ADR "A" is restored despite the pre-existing marker. */
cbm_store_t *dst = cbm_store_open_path(import_db);
ASSERT_NOT_NULL(dst);
cbm_adr_t adr = {0};
ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK);
ASSERT_NOT_NULL(adr.content);
ASSERT_STR_EQ(adr.content, "# Decision\nshared A");
cbm_store_adr_free(&adr);
cbm_store_close(dst);
cleanup_dir(g_tmpdir);
PASS();
}
/* A failed ADR restore must not be silent (#2315 follow-up). A DIRECTORY at
* "<db>.adr.db" makes the sidecar open fail deterministically (no hook). The
* graph DB is already installed when the restore runs, so the import still
* succeeds (best-effort, unchanged), but the team ADR that did not arrive is
* logged with the store's reason instead of being dropped without a trace. */
TEST(artifact_import_logs_failed_adr_restore) {
setup_artifact_test();
create_test_db(g_db);
cbm_store_t *src = cbm_store_open_path(g_db);
ASSERT_NOT_NULL(src);
ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK);
cbm_store_close(src);
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
char import_db[1024];
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
char adr_dir[1100];
snprintf(adr_dir, sizeof(adr_dir), "%s.adr.db", import_db);
ASSERT_EQ(cbm_mkdir(adr_dir), 0);
capture_logs_start();
int rc = cbm_artifact_import(g_repo, import_db);
const char *logs = capture_logs_end();
ASSERT_EQ(rc, 0); /* the graph DB is installed; the restore stays best-effort */
ASSERT_TRUE(cbm_file_exists(import_db));
const char *line = strstr(logs, "level=warn msg=artifact.import err=adr_restore");
ASSERT_NOT_NULL(line);
/* The store's reason is carried (the text log format maps spaces to '_'). */
ASSERT(strstr(line, "detail=adr_sidecar_open") != NULL);
cleanup_dir(g_tmpdir);
PASS();
}
TEST(artifact_export_best_roundtrip) {
setup_artifact_test();
create_test_db(g_db);
@@ -1172,6 +1408,11 @@ SUITE(artifact) {
RUN_TEST(artifact_repo_path_shell_safe_rejects_injection);
RUN_TEST(artifact_repo_path_shell_safe_rejects_cmd_metachars_on_windows);
RUN_TEST(artifact_export_fast_roundtrip);
RUN_TEST(artifact_export_roundtrip_keeps_adr);
RUN_TEST(artifact_export_without_adr_does_not_resurrect_legacy_row);
RUN_TEST(artifact_import_does_not_clobber_local_adr);
RUN_TEST(artifact_import_restores_adr_when_local_absent);
RUN_TEST(artifact_import_logs_failed_adr_restore);
RUN_TEST(artifact_export_best_roundtrip);
RUN_TEST(artifact_exists_check);
RUN_TEST(artifact_commit_hash);
+269
View File
@@ -6180,6 +6180,149 @@ TEST(cli_zed_mcp_jsonc_comments) {
PASS();
}
#ifndef _WIN32
TEST(cli_install_agent_config_symlinks_issue2671) {
/* Refs #2671: dotfiles checkout pattern where agent config directory is a
* user-owned symlink into dotfiles under the user's home directory. */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-symlink-test-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dotfiles_zed[512];
snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir);
test_mkdirp(dotfiles_zed);
char dotfiles_settings[512];
snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed);
write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n");
#ifdef __APPLE__
char parent_dir[512];
snprintf(parent_dir, sizeof(parent_dir), "%s/Library/Application Support", tmpdir);
test_mkdirp(parent_dir);
char zed_link[512];
snprintf(zed_link, sizeof(zed_link), "%s/Zed", parent_dir);
#else
char parent_dir[512];
snprintf(parent_dir, sizeof(parent_dir), "%s/.config", tmpdir);
test_mkdirp(parent_dir);
char zed_link[512];
snprintf(zed_link, sizeof(zed_link), "%s/zed", parent_dir);
#endif
char *saved_xdg = save_test_env("XDG_CONFIG_HOME");
char xdg_dir[640];
snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir);
cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */
/* Dangling symlink and symlink to a regular file must remain undetected. */
char dangling_target[512];
snprintf(dangling_target, sizeof(dangling_target), "%s/dotfiles/missing-zed", tmpdir);
ASSERT_EQ(symlink(dangling_target, zed_link), 0);
bool dangling_detected = cbm_detect_agents(tmpdir).zed;
ASSERT_EQ(cbm_unlink(zed_link), 0);
ASSERT_EQ(symlink(dotfiles_settings, zed_link), 0);
bool file_link_detected = cbm_detect_agents(tmpdir).zed;
ASSERT_EQ(cbm_unlink(zed_link), 0);
/* Symlink resolving to a directory not owned by the invoking user must be refused. */
char foreign_dir[512];
const char *unowned_target = "/";
if (geteuid() == 0) {
snprintf(foreign_dir, sizeof(foreign_dir), "%s/dotfiles/foreign-zed", tmpdir);
test_mkdirp(foreign_dir);
ASSERT_EQ(chown(foreign_dir, 65534, 65534), 0);
unowned_target = foreign_dir;
}
ASSERT_EQ(symlink(unowned_target, zed_link), 0);
bool unowned_target_detected = cbm_detect_agents(tmpdir).zed;
ASSERT_EQ(cbm_unlink(zed_link), 0);
ASSERT_EQ(symlink(dotfiles_zed, zed_link), 0);
cbm_cli_set_client_selection_for_testing("zed");
int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false);
cbm_cli_set_client_selection_for_testing(NULL);
restore_test_env("XDG_CONFIG_HOME", saved_xdg);
char *after = read_test_file_alloc(dotfiles_settings);
bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL;
bool has_original = after && strstr(after, "Ayu Dark") != NULL;
free(after);
struct stat st;
ASSERT_EQ(lstat(zed_link, &st), 0);
bool still_symlink = S_ISLNK(st.st_mode);
test_rmdir_r(tmpdir);
ASSERT(!dangling_detected);
ASSERT(!file_link_detected);
ASSERT(!unowned_target_detected);
ASSERT_EQ(rc, 0);
ASSERT(has_cbm);
ASSERT(has_original);
ASSERT(still_symlink);
PASS();
}
TEST(cli_install_agent_config_file_symlink_issue2671) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-file-symlink-test-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dotfiles_zed[512];
snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir);
test_mkdirp(dotfiles_zed);
char dotfiles_settings[512];
snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed);
write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n");
#ifdef __APPLE__
char zed_dir[512];
snprintf(zed_dir, sizeof(zed_dir), "%s/Library/Application Support/Zed", tmpdir);
test_mkdirp(zed_dir);
char zed_settings_link[512];
snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir);
ASSERT_EQ(symlink("../../../dotfiles/zed/settings.json", zed_settings_link), 0);
#else
char zed_dir[512];
snprintf(zed_dir, sizeof(zed_dir), "%s/.config/zed", tmpdir);
test_mkdirp(zed_dir);
char zed_settings_link[512];
snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir);
ASSERT_EQ(symlink("../../dotfiles/zed/settings.json", zed_settings_link), 0);
#endif
char *saved_xdg = save_test_env("XDG_CONFIG_HOME");
char xdg_dir[640];
snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir);
cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */
cbm_cli_set_client_selection_for_testing("zed");
int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false);
cbm_cli_set_client_selection_for_testing(NULL);
restore_test_env("XDG_CONFIG_HOME", saved_xdg);
char *after = read_test_file_alloc(dotfiles_settings);
bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL;
bool has_original = after && strstr(after, "Ayu Dark") != NULL;
free(after);
struct stat st;
ASSERT_EQ(lstat(zed_settings_link, &st), 0);
bool still_symlink = S_ISLNK(st.st_mode);
test_rmdir_r(tmpdir);
ASSERT_EQ(rc, 0);
ASSERT(has_cbm);
ASSERT(has_original);
ASSERT(still_symlink);
PASS();
}
#endif
/* ═══════════════════════════════════════════════════════════════════
* PATH management tests (port of TestCLI_InstallPATHAppend)
* ═══════════════════════════════════════════════════════════════════ */
@@ -9429,6 +9572,127 @@ TEST(cli_dry_run_predicts_refused_hook_script_issue1387) {
FAIL("dry-run must predict a refused hook-script rewrite (#1387)");
PASS();
}
/* #2204: the OpenCode adapter is an auto-loaded module, and the generated block
* tells readers to "remove the markers to take ownership". A marker-less file
* with content is the user's: appending the block to a hand-ported module
* produces a file the client cannot even parse (the prelude redeclares the
* user's own import and const names, and both sides declare a default export).
* The installer must leave that file alone, and the dry run must say so instead
* of promising a write it will not make. A dry run never writes, so the byte
* check below follows a REAL install: that is the run that appended. */
TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) {
char home[256];
char bin[320];
if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin)))
FAIL("cbm_mkdtemp failed");
/* An `opencode` CLI on the isolated PATH marks OpenCode as installed. */
char exe[512];
snprintf(exe, sizeof(exe), "%s/opencode", bin);
if (write_test_file(exe, "#!/bin/sh\nexit 0\n") != 0 || chmod(exe, 0755) != 0)
FAIL("opencode fixture failed");
char plugins_dir[512];
snprintf(plugins_dir, sizeof(plugins_dir), "%s/.config/opencode/plugins", home);
test_mkdirp(plugins_dir);
char plugin_path[768];
snprintf(plugin_path, sizeof(plugin_path), "%s/cbm-augment.ts", plugins_dir);
const char foreign[] = "import { spawn } from 'node:child_process';\n"
"const BIN = '/opt/other/codebase-memory-mcp';\n"
"export default { id: 'codebase-memory-augment', setup() {} };\n";
if (write_test_file(plugin_path, foreign) != 0)
FAIL("foreign module fixture failed");
client_detect_env_t saved = client_detect_env_isolate(bin);
FILE *capture = tmpfile();
int saved_stdout = capture ? dup(STDOUT_FILENO) : -1;
bool redirected = false;
if (capture && saved_stdout >= 0) {
fflush(stdout);
redirected = dup2(fileno(capture), STDOUT_FILENO) >= 0;
}
long plan_output_start = -1;
long plan_output_end = -1;
char *plan = NULL;
if (redirected) {
(void)cbm_install_agent_configs(home, "/opt/other/codebase-memory-mcp", false, true);
fflush(stdout);
/* The --plan receipt (#388) replays the same path with writes off. It
* must not list a module the run leaves alone, and it must print
* nothing: stdout carries the receipt JSON. */
plan_output_start = (long)lseek(STDOUT_FILENO, 0, SEEK_CUR);
plan = cbm_build_install_plan_json(home, "/opt/other/codebase-memory-mcp");
fflush(stdout);
/* The real install, the run #2204 is about. */
plan_output_end = (long)lseek(STDOUT_FILENO, 0, SEEK_CUR);
(void)cbm_install_agent_configs(home, "/opt/other/codebase-memory-mcp", false, false);
fflush(stdout);
(void)dup2(saved_stdout, STDOUT_FILENO);
}
if (saved_stdout >= 0)
close(saved_stdout);
char bytes[512] = {0};
FILE *file = cbm_fopen(plugin_path, "rb");
size_t got = file ? fread(bytes, 1U, sizeof(bytes) - 1U, file) : 0U;
if (file)
fclose(file);
/* Control: with the user's module gone the receipt does list the path, so
* the omission above cannot pass just because OpenCode went undetected. */
char *control_plan = NULL;
if (unlink(plugin_path) == 0)
control_plan = cbm_build_install_plan_json(home, "/opt/other/codebase-memory-mcp");
client_detect_env_restore(&saved);
char output[16384] = {0};
if (capture) {
rewind(capture);
size_t count = fread(output, 1, sizeof(output) - 1U, capture);
output[count] = '\0';
fclose(capture);
}
/* Each phase owns its slice of stdout: [0, start) dry run, [start, end)
* plan mode, [end, len) real install, so no phase passes on another's
* line. Checked back to front, cutting the buffer at each boundary. */
bool sliced = plan_output_start >= 0 && plan_output_end >= plan_output_start &&
(size_t)plan_output_end <= strlen(output);
bool reported =
sliced && strstr(output + plan_output_end, "cbm-augment.ts left untouched") != NULL;
if (sliced)
output[plan_output_end] = '\0';
bool plan_silent = sliced && strstr(output + plan_output_start, "cbm-augment.ts") == NULL;
if (sliced)
output[plan_output_start] = '\0';
bool warned =
sliced && strstr(output, "cbm-augment.ts") != NULL && strstr(output, "not ours") != NULL;
bool untouched = got == sizeof(foreign) - 1U && memcmp(bytes, foreign, got) == 0;
bool plan_omits = plan && strstr(plan, "cbm-augment.ts") == NULL;
bool control_lists = control_plan && strstr(control_plan, "cbm-augment.ts") != NULL;
free(plan);
free(control_plan);
test_rmdir_r(home);
if (!redirected)
FAIL("stdout capture failed");
if (!sliced)
FAIL("stdout capture lost a phase boundary");
if (!warned)
FAIL("dry-run must predict an untouched user-owned OpenCode module (#2204)");
if (!untouched)
FAIL("a real install must leave the user-owned OpenCode module byte-identical (#2204)");
if (!reported)
FAIL("a real install must report the user-owned OpenCode module as left untouched (#2204)");
if (!plan_omits)
FAIL("the install plan must not list a module the run leaves untouched (#2204)");
if (!plan_silent)
FAIL("plan mode must keep stdout for the receipt JSON (#2204)");
if (!control_lists)
FAIL("control: the install plan must list the module once it is not user-owned");
PASS();
}
#endif
TEST(cli_agent_client_registry_routes_plan_install_and_uninstall) {
@@ -19675,6 +19939,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);
@@ -19759,6 +20027,7 @@ SUITE(cli) {
#endif
#ifndef _WIN32
RUN_TEST(cli_dry_run_predicts_refused_hook_script_issue1387);
RUN_TEST(cli_extension_leaves_user_owned_module_untouched_issue2204);
#endif
RUN_TEST(cli_agent_client_registry_routes_plan_install_and_uninstall);
RUN_TEST(cli_registry_installs_kimi_rovo_amp_durable_context);
+40
View File
@@ -402,6 +402,45 @@ TEST(cv_elixir_compile_time_if) {
PASS();
}
/* A multi-clause function under a compile-time `if`. Each branch's clauses
* fold into one definition spanning them (the clause fold), and the two
* branches stay two variants of one identity: exactly the spans 5-6 and 8-9,
* neither one span per clause (no fold) nor one span over both branches (a
* fold across the branch boundary). */
TEST(cv_elixir_multi_clause_under_if) {
static const cv_file_t files[] = {{"pick.ex", "defmodule Pick do\n" /* 1 */
" def alpha(_), do: :ok\n" /* 2 */
" def beta(_), do: :ok\n" /* 3 */
" if Code.ensure_loaded?(:win32) do\n" /* 4 */
" def pick(0), do: alpha(0)\n" /* 5 */
" def pick(n), do: alpha(n)\n" /* 6 */
" else\n" /* 7 */
" def pick(0), do: beta(0)\n" /* 8 */
" def pick(n), do: beta(n)\n" /* 9 */
" end\n" /* 10 */
" def caller, do: pick(1)\n" /* 11 */
"end\n"}}; /* 12 */
cv_proj_t p;
ASSERT_TRUE(cv_index(&p, files, 1));
cv_result_t r = cv_measure(&p, "pick", "alpha", "beta", "caller");
const char *span_do =
"SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v "
"WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS
" AND json_extract(v.value,'$.start_line')=5 AND json_extract(v.value,'$.end_line')=6";
const char *span_else =
"SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v "
"WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS
" AND json_extract(v.value,'$.start_line')=8 AND json_extract(v.value,'$.end_line')=9";
int do_spans = cv_count(&p, span_do, "pick", NULL);
int else_spans = cv_count(&p, span_else, "pick", NULL);
cv_cleanup(&p);
ASSERT_TRUE(cv_expect("elixir_multi_clause", r));
ASSERT_EQ(r.variant_spans, 2);
ASSERT_EQ(do_spans, 1);
ASSERT_EQ(else_spans, 1);
PASS();
}
/* Verilog: the identity only. Module instantiation produces no CALLS edge in
* this graph even without any `ifdef (a general Verilog gap, not a variant
* one), so the body and caller edges are not this suite's to assert. */
@@ -798,6 +837,7 @@ SUITE(conditional_variants) {
RUN_TEST(cv_pascal_ifdef);
RUN_TEST(cv_julia_static_if);
RUN_TEST(cv_elixir_compile_time_if);
RUN_TEST(cv_elixir_multi_clause_under_if);
RUN_TEST(cv_verilog_ifdef);
RUN_TEST(cv_c_platform_files);
RUN_TEST(cv_c_platform_directories);
+39
View File
@@ -725,6 +725,44 @@ TEST(config_text_write_owned_document_if_unchanged_rejects_stale_snapshot) {
PASS();
}
/* #2204: the generated OpenCode module must not be appended to a file the user
* owns. The probe separates "may be written" (absent, whitespace-only, or
* already carrying our markers) from "the user's file" (content, no markers) so
* the installer can leave it alone instead of producing an unparseable hybrid. */
TEST(config_text_managed_block_status_reports_foreign_module) {
char dir[CTE_PATH_CAP];
char path[CTE_PATH_CAP];
const char whitespace[] = " \n\t\n";
const char foreign[] = "import { spawn } from 'node:child_process';\n"
"export default { id: 'codebase-memory-augment', setup() {} };\n";
const char unbalanced[] = CTE_BEGIN "\nowned\n";
ASSERT_EQ(cte_fixture(dir, sizeof(dir), path, sizeof(path)), 0);
/* No file yet, an empty file, and a whitespace-only file are all writable. */
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
ASSERT_EQ(cte_write_bytes(path, "", 0U), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
ASSERT_EQ(cte_write_bytes(path, whitespace, sizeof(whitespace) - 1U), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
/* Our own block refreshes in place, so it is writable too. */
ASSERT_EQ(cbm_text_upsert_managed_block(path, CTE_BEGIN, CTE_END, "owned"), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
/* A hand-ported module with no markers is the user's: refuse, never append. */
ASSERT_EQ(cte_write_bytes(path, foreign, sizeof(foreign) - 1U), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 1);
/* Unbalanced markers and malformed marker arguments are unsafe states. */
ASSERT_EQ(cte_write_bytes(path, unbalanced, sizeof(unbalanced) - 1U), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), -1);
ASSERT_EQ(cbm_text_managed_block_status(path, "same", "same"), -1);
ASSERT_EQ(cbm_text_managed_block_status(NULL, CTE_BEGIN, CTE_END), -1);
th_cleanup(dir);
PASS();
}
SUITE(config_text_edit) {
RUN_TEST(config_text_managed_insert_preserves_bom_comments_and_is_idempotent);
RUN_TEST(config_text_managed_replace_preserves_crlf_surroundings);
@@ -732,6 +770,7 @@ SUITE(config_text_edit) {
RUN_TEST(config_text_managed_remove_preserves_user_bytes);
RUN_TEST(config_text_managed_malformed_markers_fail_closed);
RUN_TEST(config_text_managed_rejects_unsafe_markers_and_owned_content);
RUN_TEST(config_text_managed_block_status_reports_foreign_module);
RUN_TEST(config_text_owned_document_write_remove_exact_only);
RUN_TEST(config_text_owned_document_migrates_exact_releases_only);
RUN_TEST(config_text_rejects_invalid_utf8_and_controls);
+191
View File
@@ -944,10 +944,201 @@ TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) {
PASS();
}
static bool cross_repo_seed_server_route(const cross_repo_fixture_t *fixture,
const char *target_project, const char *method,
const char *route_path, const char *handler_name) {
char target_path[512];
if (!cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) {
return false;
}
cbm_store_t *target = cbm_store_open_path(target_path);
if (!target) {
return false;
}
bool ok = cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK;
char target_route_qn[256];
char handler_qn[256];
char route_name[128];
snprintf(target_route_qn, sizeof(target_route_qn), "__route__%s__%s", method, route_path);
snprintf(handler_qn, sizeof(handler_qn), "%s.%s", target_project, handler_name);
snprintf(route_name, sizeof(route_name), "%s %s", method, route_path);
cbm_node_t target_route = {.project = target_project,
.label = "Route",
.name = route_name,
.qualified_name = target_route_qn,
.file_path = "server.java"};
cbm_node_t handler = {.project = target_project,
.label = "Function",
.name = handler_name,
.qualified_name = handler_qn,
.file_path = "server.java"};
int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0;
int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0;
cbm_edge_t handles = {.project = target_project,
.source_id = handler_id,
.target_id = target_route_id,
.type = "HANDLES"};
ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0;
cbm_store_close(target);
return ok;
}
static bool cross_repo_seed_client_call(const cross_repo_fixture_t *fixture,
const char *source_project, const char *caller_name,
const char *callee_name, const char *url_path,
const char *method) {
char source_path[512];
if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) {
return false;
}
cbm_store_t *source = cbm_store_open_path(source_path);
if (!source) {
return false;
}
bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK;
char caller_qn[256];
char local_route_qn[256];
char edge_props[512];
snprintf(caller_qn, sizeof(caller_qn), "%s.%s", source_project, caller_name);
snprintf(local_route_qn, sizeof(local_route_qn), "__route__%s__%s", method ? method : "ANY",
url_path);
if (method) {
snprintf(edge_props, sizeof(edge_props),
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"method\":\"%s\"}", callee_name,
url_path, method);
} else {
snprintf(edge_props, sizeof(edge_props),
"{\"callee\":\"%s\",\"url_path\":\"%s\"}", callee_name, url_path);
}
cbm_node_t caller = {.project = source_project,
.label = "Function",
.name = caller_name,
.qualified_name = caller_qn,
.file_path = "item.service.ts"};
cbm_node_t local_route = {.project = source_project,
.label = "Route",
.name = url_path,
.qualified_name = local_route_qn,
.file_path = "item.service.ts"};
int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0;
int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0;
cbm_edge_t http_call = {.project = source_project,
.source_id = caller_id,
.target_id = local_route_id,
.type = "HTTP_CALLS",
.properties_json = edge_props};
ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0;
cbm_store_close(source);
return ok;
}
static int cross_repo_count_edges_like(const cross_repo_fixture_t *fixture, const char *project,
const char *edge_type, const char *pattern) {
char path[512];
if (!cross_repo_project_path(fixture, project, path, sizeof(path))) {
return -1;
}
cbm_store_t *store = cbm_store_open_path_query(path);
if (!store) {
return -1;
}
sqlite3_stmt *s = NULL;
int count = -1;
if (sqlite3_prepare_v2(
cbm_store_get_db(store),
"SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2 AND properties LIKE ?3",
-1, &s, NULL) == SQLITE_OK) {
sqlite3_bind_text(s, 1, project, -1, SQLITE_STATIC);
sqlite3_bind_text(s, 2, edge_type, -1, SQLITE_STATIC);
sqlite3_bind_text(s, 3, pattern, -1, SQLITE_STATIC);
if (sqlite3_step(s) == SQLITE_ROW) {
count = sqlite3_column_int(s, 0);
}
sqlite3_finalize(s);
}
cbm_store_close(store);
return count;
}
/* #2768: Angular HttpClient calls must match cross-repo routes with exact HTTP verbs.
* When client makes GET requests to /items/list, /items/42, and /items/job/10,
* and the server defines overlapping routes:
* DELETE /items/{id} (seeded first in DB)
* GET /items/{id} (seeded second in DB)
* POST /items/{parentId}/{id} (seeded first in DB)
* GET /items/job/{jobId} (seeded second in DB)
* the fuzzy route matcher must enforce method compatibility, linking the GET calls
* to their respective GET endpoints and never to DELETE or POST endpoints.
* getItem and getJob are seeded WITHOUT a method property (an edge written by an
* index that did not record the verb): the matcher must infer GET from the
* callee's verb suffix, or the method-less fuzzy match lands on the DELETE / POST
* route seeded first. getItems keeps its explicit "GET". */
TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) {
cross_repo_fixture_t fixture;
bool setup = cross_repo_fixture_begin(&fixture) &&
/* Target server with overlapping endpoints */
cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/list",
"handle_list_items") &&
cross_repo_seed_server_route(&fixture, "api-2768", "DELETE", "/items/{}",
"handle_delete_item") &&
cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/{}",
"handle_get_item") &&
cross_repo_seed_server_route(&fixture, "api-2768", "POST", "/items/{}/{}",
"handle_post_nested_item") &&
cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/job/{}",
"handle_get_job") &&
/* Angular client making GET requests */
cross_repo_seed_client_call(&fixture, "angular-2768", "getItems",
"this.http.get", "/items/list", "GET") &&
cross_repo_seed_client_call(&fixture, "angular-2768", "getItem",
"this.http.get", "/items/42", NULL) &&
cross_repo_seed_client_call(&fixture, "angular-2768", "getJob",
"this.http.get", "/items/job/10", NULL);
if (!setup) {
cross_repo_fixture_end(&fixture);
FAIL("failed to seed issue 2768 fixture");
}
const char *target = "api-2768";
cbm_cross_repo_result_t fwd = cbm_cross_repo_match("angular-2768", &target, 1);
ASSERT_FALSE(fwd.failed);
ASSERT_EQ(fwd.http_edges, 3);
/* Assert that client CROSS_HTTP_CALLS edges are linked to GET handlers, not DELETE or POST */
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
"%handle_delete_item%"),
0);
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
"%handle_post_nested_item%"),
0);
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
"%handle_list_items%"),
1);
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
"%handle_get_item%"),
1);
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
"%handle_get_job%"),
1);
/* Reverse pass */
const char *consumer = "angular-2768";
cbm_cross_repo_result_t rev = cbm_cross_repo_match("api-2768", &consumer, 1);
ASSERT_FALSE(rev.failed);
ASSERT_EQ(rev.http_edges, 3);
cross_repo_fixture_end(&fixture);
cbm_cross_repo_result_free(&fwd);
cbm_cross_repo_result_free(&rev);
PASS();
}
SUITE(cross_repo) {
RUN_TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459);
RUN_TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459);
RUN_TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459);
RUN_TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768);
RUN_TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609);
RUN_TEST(cross_repo_null_target_fails_without_dereference);
RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens);
+171
View File
@@ -1226,6 +1226,174 @@ TEST(cslsp_ordinary_same_leaf_calls_join_by_exact_site) {
PASS();
}
/* A qualified type name resolves its qualifier as a TYPE when no namespace
* binds it (nested types, #2120). A using alias whose target is qualified by
* the alias itself (`using Loop = Loop.Inner;`) or by a mutual alias re-enters
* that walk through the alias step forever: each `Loop` resolves the target
* `Loop.Inner`, whose qualifier is `Loop` again. The walk is depth-bounded;
* the file must still finish and resolve its ordinary calls. */
TEST(cslsp_cyclic_alias_qualifier_terminates) {
const char *src = "using Loop = Loop.Inner;\n"
"using Ping = Pong.Inner;\n"
"using Pong = Ping.Inner;\n"
"namespace App {\n"
" public class Svc { public void Go() { } }\n"
" public class Runner {\n"
" public void Run(Loop a, Ping b) {\n"
" var x = new Loop();\n"
" var y = new Ping.Leaf();\n"
" var s = new Svc();\n"
" s.Go();\n"
" }\n"
" }\n"
"}\n";
CBMFileResult *r = extract_cs(src);
ASSERT(r);
ASSERT(require_resolved(r, "Runner.Run", "Svc.Go") >= 0);
cbm_free_result(r);
PASS();
}
static const CBMDefinition *find_def_named(const 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 top-level C# type records its declared namespace; "" means the global
* namespace and is claimed only on a clean parse. The vendored grammar does
* not parse a `ref partial struct` with members, and error recovery dissolves
* the enclosing namespace, so the types after it surface with no namespace
* ancestor. Those must not read as global (they would hide every imported
* type of the same name); unknown (NULL) keeps them out of namespace binding
* exactly as before global-namespace binding existed. */
TEST(cslsp_decl_namespace_global_needs_clean_parse) {
CBMFileResult *clean = extract_cs("public class Loose { }\n"
"namespace Acme.Lib { public class Kept { } }\n");
ASSERT(clean);
const CBMDefinition *loose = find_def_named(clean, "Loose");
const CBMDefinition *kept = find_def_named(clean, "Kept");
ASSERT(loose && kept);
ASSERT(loose->decl_namespace && strcmp(loose->decl_namespace, "") == 0);
ASSERT(kept->decl_namespace && strcmp(kept->decl_namespace, "Acme.Lib") == 0);
cbm_free_result(clean);
CBMFileResult *damaged = extract_cs("namespace Acme.Lib\n"
"{\n"
" public ref partial struct Handler\n"
" {\n"
" public void Append(string value) { }\n"
" }\n"
" public static partial class Gadget { }\n"
"}\n");
ASSERT(damaged);
const CBMDefinition *gadget = find_def_named(damaged, "Gadget");
ASSERT(gadget);
if (gadget->decl_namespace && !gadget->decl_namespace[0])
printf(" Gadget after a parse error claims the global namespace\n");
ASSERT(!gadget->decl_namespace || gadget->decl_namespace[0]);
cbm_free_result(damaged);
PASS();
}
static bool has_resolved_row(const CBMResolvedCallArray *out, const char *caller_qn,
const char *callee_qn) {
for (int i = 0; i < out->count; i++) {
const CBMResolvedCall *rc = &out->items[i];
if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 &&
strcmp(rc->callee_qn, callee_qn) == 0)
return true;
}
return false;
}
/* `partial` pieces of one namespaced type are separate registry entries, so
* a member declared on another piece still binds (#2120). Types declared in
* the GLOBAL namespace carry the empty namespace, and the same simple name
* there is a different type per assembly across a repository (each test
* project's own `Helper`). They must never pool as twins: the twin probe runs
* before the base-class walk, so pooling bound `h.Stop()` on ToolA's Helper
* to the unrelated ToolB Helper instead of ToolA's own base ToolABase. Runs
* the production Tier-2 C# registry builder over cross-file defs. */
TEST(cslsp_global_namespace_types_are_not_partial_twins) {
CBMArena arena;
cbm_arena_init(&arena);
CBMLSPDef defs[] = {
{.qualified_name = "p.ToolA.Helper.Helper",
.short_name = "Helper",
.label = "Class",
.def_module_qn = "p.ToolA.Helper",
.embedded_types = "p.ToolA.Base.ToolABase",
.lang = CBM_LANG_CSHARP},
{.qualified_name = "p.ToolA.Helper.Helper.Start",
.short_name = "Start",
.label = "Method",
.receiver_type = "p.ToolA.Helper.Helper",
.def_module_qn = "p.ToolA.Helper",
.lang = CBM_LANG_CSHARP},
{.qualified_name = "p.ToolA.Base.ToolABase",
.short_name = "ToolABase",
.label = "Class",
.def_module_qn = "p.ToolA.Base",
.lang = CBM_LANG_CSHARP},
{.qualified_name = "p.ToolA.Base.ToolABase.Stop",
.short_name = "Stop",
.label = "Method",
.receiver_type = "p.ToolA.Base.ToolABase",
.def_module_qn = "p.ToolA.Base",
.lang = CBM_LANG_CSHARP},
{.qualified_name = "p.ToolB.Helper.Helper",
.short_name = "Helper",
.label = "Class",
.def_module_qn = "p.ToolB.Helper",
.lang = CBM_LANG_CSHARP},
{.qualified_name = "p.ToolB.Helper.Helper.Stop",
.short_name = "Stop",
.label = "Method",
.receiver_type = "p.ToolB.Helper.Helper",
.def_module_qn = "p.ToolB.Helper",
.lang = CBM_LANG_CSHARP},
};
CBMTypeRegistry *reg =
cbm_cs_build_cross_registry(&arena, defs, (int)(sizeof(defs) / sizeof(defs[0])));
ASSERT_NOT_NULL(reg);
/* No namespace anywhere: both Helpers and the base live in the global
* namespace. The caller sits in ToolA's Helper file, so `new Helper()`
* binds ToolA's Helper through the same-module step, independent of
* registration order. */
const char *src = "public class Helper : ToolABase { public void Start() { } }\n"
"public class Caller {\n"
" public void Go() {\n"
" var h = new Helper();\n"
" h.Start();\n"
" h.Stop();\n"
" }\n"
"}\n";
CBMResolvedCallArray out = {0};
cbm_run_cs_lsp_cross_with_registry(&arena, src, (int)strlen(src), "p.ToolA.Helper", reg, NULL,
0, NULL, &out);
const char *caller = "p.ToolA.Helper.Caller.Go";
bool start_own = has_resolved_row(&out, caller, "p.ToolA.Helper.Helper.Start");
bool stop_base = has_resolved_row(&out, caller, "p.ToolA.Base.ToolABase.Stop");
bool stop_twin = has_resolved_row(&out, caller, "p.ToolB.Helper.Helper.Stop");
if (!start_own || !stop_base || stop_twin) {
printf(" start_own=%d stop_base=%d stop_unrelated_global_twin=%d\n", start_own, stop_base,
stop_twin);
for (int i = 0; i < out.count; i++) {
printf(" %s -> %s [%s]\n", out.items[i].caller_qn ? out.items[i].caller_qn : "?",
out.items[i].callee_qn ? out.items[i].callee_qn : "?",
out.items[i].strategy ? out.items[i].strategy : "?");
}
}
cbm_arena_destroy(&arena);
ASSERT_TRUE(start_own);
ASSERT_TRUE(stop_base);
ASSERT_FALSE(stop_twin);
PASS();
}
/* ── Suite ─────────────────────────────────────────────────── */
SUITE(cs_lsp) {
@@ -1290,4 +1458,7 @@ SUITE(cs_lsp) {
RUN_TEST(cslsp_stringbuilder_chain);
RUN_TEST(cslsp_httpclient_chain);
RUN_TEST(cslsp_ordinary_same_leaf_calls_join_by_exact_site);
RUN_TEST(cslsp_cyclic_alias_qualifier_terminates);
RUN_TEST(cslsp_global_namespace_types_are_not_partial_twins);
RUN_TEST(cslsp_decl_namespace_global_needs_clean_parse);
}
+89 -3
View File
@@ -1662,6 +1662,7 @@ typedef struct {
* itself instead, which is the edge a reader on another thread needs. */
atomic_bool args_captured[APP_FAKE_MAX_ATTEMPTS];
size_t memory_budgets[APP_FAKE_MAX_ATTEMPTS];
bool background_requests[APP_FAKE_MAX_ATTEMPTS];
} app_fake_worker_context_t;
typedef struct {
@@ -1729,6 +1730,8 @@ static int app_fake_worker_start(void *opaque, const char *args_json, size_t mem
args_json ? args_json : "");
atomic_store(&context->args_captured[worker->attempt], true);
context->memory_budgets[worker->attempt] = memory_budget_bytes;
context->background_requests[worker->attempt] =
cbm_mcp_get_bool_arg(args_json, "_cbm_background");
if (marker_file) {
(void)snprintf(context->marker_paths[worker->attempt], APP_TEST_PATH_CAP, "%s",
marker_file);
@@ -2546,6 +2549,7 @@ TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions) {
ASSERT_TRUE(first_initialized);
ASSERT_TRUE(first_owned);
ASSERT_TRUE(auto_policy_propagated);
ASSERT_TRUE(fake.background_requests[0]);
ASSERT_TRUE(second_initialized);
ASSERT_TRUE(coalesced);
ASSERT_TRUE(restricted_disconnect_kept_job);
@@ -4287,6 +4291,7 @@ TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session) {
ASSERT_TRUE(thread_started);
ASSERT_TRUE(subscribed);
ASSERT_TRUE(worker_started);
ASSERT_FALSE(fixture.fake.background_requests[0]);
ASSERT_TRUE(request_returned);
ASSERT_TRUE(thread_joined);
ASSERT_EQ(request.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED);
@@ -4584,6 +4589,7 @@ TEST(daemon_application_final_cancel_drains_admitted_watcher_job) {
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(thread_started);
ASSERT_TRUE(worker_started);
ASSERT_TRUE(fixture.fake.background_requests[0]);
ASSERT_TRUE(job_active);
ASSERT_EQ(watches_after_cancel, 0);
ASSERT_TRUE(thread_joined);
@@ -7292,8 +7298,9 @@ typedef struct {
uint32_t context_length;
} app_async_fixture_t;
static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *label,
bool permanent) {
/* stored_config is optional; a test that needs auto_index passes its own. */
static bool app_async_fixture_init_with_config(app_async_fixture_t *fixture, const char *label,
bool permanent, cbm_config_t *stored_config) {
memset(fixture, 0, sizeof(*fixture));
app_fake_worker_context_init(&fixture->fake);
fixture->ops = (cbm_daemon_application_worker_ops_t){
@@ -7304,7 +7311,8 @@ static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *lab
.log_path = app_fake_worker_log_path,
.destroy = app_fake_worker_destroy,
};
cbm_daemon_application_config_t config = {.worker_ops = &fixture->ops};
cbm_daemon_application_config_t config = {.config = stored_config,
.worker_ops = &fixture->ops};
fixture->application = cbm_daemon_application_new(&config);
if (!fixture->application) {
return false;
@@ -7325,6 +7333,11 @@ static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *lab
&fixture->context_length);
}
static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *label,
bool permanent) {
return app_async_fixture_init_with_config(fixture, label, permanent, NULL);
}
static cbm_daemon_runtime_application_session_t *app_async_session(app_async_fixture_t *fixture,
cbm_daemon_client_id_t id) {
cbm_daemon_runtime_application_session_t *session = app_test_open(&fixture->callbacks, id);
@@ -7702,6 +7715,78 @@ TEST(daemon_application_cut_short_sync_index_advice_reaches_next_call) {
* the daemon's only live session is refused async (the daemon would stop and
* cancel the job as soon as the command exits), while status stays allowed and
* a long-lived MCP session on the same temporary daemon remains a valid host. */
/* Automatic indexing (session auto-index, watcher re-index) marks its job
* _cbm_background so the worker leaves CPU headroom. The marker picks the
* worker count, never what is indexed, so an explicit index_repository for the
* same root still JOINS the automatic job already running for it, the contract
* the repo_path fold above restored, instead of being refused as an options
* conflict. The worker is held, so the join is observed while the job runs. */
TEST(daemon_application_explicit_index_joins_running_auto_index_job) {
app_env_backup_t cache_environment;
bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR");
char cache[APP_TEST_PATH_CAP];
(void)snprintf(cache, sizeof(cache), "%s/cbm-app-auto-join-cache-XXXXXX", cbm_tmpdir());
bool cache_ready =
cache_saved && cbm_mkdtemp(cache) != NULL && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0;
cbm_config_t *stored_config = cache_ready ? cbm_config_open(cache) : NULL;
bool config_ready = stored_config &&
cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "true") == 0 &&
cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "false") == 0;
app_async_fixture_t fixture;
bool setup = config_ready &&
app_async_fixture_init_with_config(&fixture, "auto-join", true, stored_config);
cbm_daemon_runtime_application_session_t *automatic =
setup ? app_test_open(&fixture.callbacks, 7801) : NULL;
bool auto_started = automatic &&
app_test_initialize_profile(&fixture.callbacks, automatic, fixture.root,
CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL) &&
app_wait_for_atomic_int(&fixture.fake.starts, 1);
/* args_json is published by the args_captured release store; the
* background_requests slot is written after it, so read the args. */
bool auto_marked = auto_started &&
app_wait_for_atomic_bool(&fixture.fake.args_captured[0], true) &&
cbm_mcp_get_bool_arg(fixture.fake.args_json[0], "_cbm_background");
cbm_daemon_runtime_application_session_t *explicit_session =
auto_marked ? app_async_session(&fixture, 7802) : NULL;
char *joined =
explicit_session ? app_async_call_now(&fixture, explicit_session, ",\"async\":true") : NULL;
int starts_while_running = atomic_load(&fixture.fake.starts);
size_t active_while_running =
setup ? cbm_daemon_application_active_jobs(fixture.application) : 0;
if (explicit_session) {
fixture.callbacks.session_close(fixture.callbacks.context, explicit_session);
}
if (automatic) {
fixture.callbacks.session_cancel(fixture.callbacks.context, automatic);
fixture.callbacks.session_close(fixture.callbacks.context, automatic);
}
bool stopped = !setup || app_async_fixture_finish(&fixture);
cbm_config_close(stored_config);
bool cleaned = !cache_ready || th_rmtree(cache) == 0;
bool cache_restored = app_env_backup_restore(&cache_environment);
ASSERT_TRUE(cache_ready);
ASSERT_TRUE(config_ready);
ASSERT_TRUE(setup);
ASSERT_TRUE(auto_started);
ASSERT_TRUE(auto_marked);
ASSERT_NOT_NULL(explicit_session);
ASSERT_NOT_NULL(joined);
ASSERT_FALSE(app_async_contains(joined, "different options"));
ASSERT_FALSE(app_async_contains(joined, "\"isError\":true"));
ASSERT_TRUE(app_async_contains(joined, "\"joined\":true"));
ASSERT_EQ(starts_while_running, 1);
ASSERT_EQ(active_while_running, 1);
ASSERT_TRUE(stopped);
ASSERT_TRUE(cleaned);
ASSERT_TRUE(cache_restored);
free(joined);
PASS();
}
TEST(daemon_application_async_refused_for_lone_one_shot_client_of_temporary_daemon) {
app_async_fixture_t fixture;
bool setup = app_async_fixture_init(&fixture, "async-refuse", false);
@@ -8031,6 +8116,7 @@ SUITE(daemon_application) {
RUN_TEST(daemon_application_async_and_status_validation_errors);
RUN_TEST(daemon_application_cancelled_sync_index_reply_offers_async);
RUN_TEST(daemon_application_cut_short_sync_index_advice_reaches_next_call);
RUN_TEST(daemon_application_explicit_index_joins_running_auto_index_job);
RUN_TEST(daemon_application_async_refused_for_lone_one_shot_client_of_temporary_daemon);
RUN_TEST(daemon_application_unnamed_status_finds_async_job_of_root_owner_issue2134);
RUN_TEST(daemon_application_unnamed_status_keeps_path_derived_key_issue2134);
+776
View File
@@ -100,6 +100,47 @@ static int count_defs_named(CBMFileResult *r, const char *label, const char *nam
return count;
}
/* Count calls to `callee` whose enclosing scope is exactly `scope_qn`. A call the
* scope walk failed to place carries the FILE or module QN here, so a bare count
* of the callee passes just as happily with every call homed on the file. */
static int count_calls_from(CBMFileResult *r, const char *callee, const char *scope_qn) {
int count = 0;
for (int i = 0; i < r->calls.count; i++) {
if (r->calls.items[i].callee_name && r->calls.items[i].enclosing_func_qn &&
strcmp(r->calls.items[i].callee_name, callee) == 0 &&
strcmp(r->calls.items[i].enclosing_func_qn, scope_qn) == 0) {
count++;
}
}
return count;
}
/* Count usages of `ref` attributed to exactly `scope_qn`, and to any scope. A
* def head is a BINDING, not a usage, so the pair separates two different
* mistakes: too few rows means a read was swallowed by the binding, too many
* means a binding site was counted as a read. */
static int count_usages_from(CBMFileResult *r, const char *ref, const char *scope_qn) {
int count = 0;
for (int i = 0; i < r->usages.count; i++) {
if (r->usages.items[i].ref_name && r->usages.items[i].enclosing_func_qn &&
strcmp(r->usages.items[i].ref_name, ref) == 0 &&
strcmp(r->usages.items[i].enclosing_func_qn, scope_qn) == 0) {
count++;
}
}
return count;
}
static int count_usages_named(CBMFileResult *r, const char *ref) {
int count = 0;
for (int i = 0; i < r->usages.count; i++) {
if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, ref) == 0) {
count++;
}
}
return count;
}
static int count_calls_named(CBMFileResult *r, const char *callee) {
int count = 0;
for (int i = 0; i < r->calls.count; i++) {
@@ -1896,6 +1937,730 @@ TEST(elixir_function) {
PASS();
}
/* ── Elixir def-head guard helpers (internal/cbm/helpers.c) ─────────
*
* `def name(args) when guard` parses its WHOLE head as one `when`
* binary_operator whose left operand is the `name(args)` call, and Elixir
* admits more than one guard on a clause, which tree-sitter-elixir nests
* right-associatively. Four walks read that same first argument and each asks a
* different question of it: the defs and call-scope walks want the head UNDER
* every guard, the usages walk wants it so that a parameter READ by the guard
* stays outside the binding, and the calls walk wants to know whether the node
* it is visiting IS that head or one of the guard operators above it.
*
* That last question is why cbm_elixir_def_head_is exists beside the unwrap,
* and it is exercised here on the AST rather than through cbm_extract_file
* because the two routes it has to answer for are indistinguishable from
* outside: `def f(x) when g` reaches the calls walk as the inner `f(x)` call,
* which the unwrap reaches, while `def f when g` has no inner call at all and
* reaches it as the `when` operator itself, which the unwrap does not. A test
* that only drives one walk sees whichever of the two that walk happens to hit.
*
* This file's helpers change no extraction behaviour on their own; the walks
* that consume them are separate changes. */
enum { ELIXIR_HEAD_PROBE_MAX = 16, ELIXIR_HEAD_STACK_MAX = 256 };
static bool head_probe_text_is(const char *source, TSNode node, const char *want) {
if (ts_node_is_null(node)) {
return false;
}
uint32_t start = ts_node_start_byte(node);
uint32_t end = ts_node_end_byte(node);
size_t len = strlen(want);
return end >= start && (size_t)(end - start) == len && memcmp(source + start, want, len) == 0;
}
/* The first argument of every `def`/`defp` call under `root`, in source order.
* That argument is what all four walks read, so it is what the helpers take. */
typedef struct {
TSNode signature[ELIXIR_HEAD_PROBE_MAX];
int count;
} elixir_head_probe_t;
static void collect_elixir_def_signatures(TSNode root, const char *source,
elixir_head_probe_t *out) {
TSNode stack[ELIXIR_HEAD_STACK_MAX];
int top = 0;
stack[top++] = root;
while (top > 0) {
TSNode cur = stack[--top];
uint32_t cc = ts_node_child_count(cur);
if (cc > 1 && strcmp(ts_node_type(cur), "call") == 0 &&
(head_probe_text_is(source, ts_node_child(cur, 0), "def") ||
head_probe_text_is(source, ts_node_child(cur, 0), "defp")) &&
out->count < ELIXIR_HEAD_PROBE_MAX) {
TSNode args = ts_node_child(cur, 1);
if (!ts_node_is_null(args) && ts_node_child_count(args) > 0) {
out->signature[out->count++] = ts_node_child(args, 0);
}
}
for (int i = (int)cc - 1; i >= 0 && top < ELIXIR_HEAD_STACK_MAX; i--) {
stack[top++] = ts_node_child(cur, (uint32_t)i);
}
}
}
TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it) {
const char *source = "defmodule Heads do\n"
" def plain(x), do: x\n"
" def guarded(x) when is_binary(x), do: x\n"
" def bare when true, do: :ok\n"
" def twice(x) when is_atom(x) when is_binary(x), do: x\n"
" def a + b, do: {a, b}\n"
"end\n";
const TSLanguage *language = cbm_ts_language(CBM_LANG_ELIXIR);
ASSERT_NOT_NULL(language);
TSParser *parser = ts_parser_new();
ASSERT_NOT_NULL(parser);
ASSERT_TRUE(ts_parser_set_language(parser, language));
TSTree *tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)strlen(source));
ASSERT_NOT_NULL(tree);
elixir_head_probe_t probe;
probe.count = 0;
collect_elixir_def_signatures(ts_tree_root_node(tree), source, &probe);
ASSERT_EQ(5, probe.count);
/* Unguarded: nothing to peel, and the head is the whole first argument.
* The name node inside it is NOT the head -- the calls walk compares the
* node it is visiting, and the `plain` identifier is one of those. */
TSNode plain = probe.signature[0];
ASSERT_FALSE(cbm_elixir_is_when_guard(plain));
ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(plain), plain));
ASSERT_TRUE(cbm_elixir_def_head_is(plain, plain));
ASSERT_FALSE(cbm_elixir_def_head_is(plain, ts_node_child(plain, 0)));
/* One guard: the first argument is the operator, the head is its left
* operand, and BOTH answer true -- the operator is the node the paren-less
* route hands the calls walk, the inner call is the node the peel route
* hands it. The guard expression answers false, or the walk would suppress
* a real call written inside a guard. */
TSNode guarded = probe.signature[1];
ASSERT_TRUE(cbm_elixir_is_when_guard(guarded));
TSNode guarded_head = cbm_elixir_def_head_unwrap_guard(guarded);
ASSERT_FALSE(ts_node_eq(guarded_head, guarded));
ASSERT_STR_EQ("call", ts_node_type(guarded_head));
ASSERT_TRUE(head_probe_text_is(source, guarded_head, "guarded(x)"));
ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded));
ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded_head));
TSNode guard_expr = ts_node_child_by_field_name(guarded, TS_FIELD("right"));
ASSERT_FALSE(ts_node_is_null(guard_expr));
ASSERT_TRUE(head_probe_text_is(source, guard_expr, "is_binary(x)"));
ASSERT_FALSE(cbm_elixir_def_head_is(guarded, guard_expr));
/* Paren-less: the head under the guard is a bare identifier, so there is no
* inner call for the peel route to produce. This is the shape that makes
* cbm_elixir_def_head_is necessary: the only node the calls walk ever sees
* for this clause is the operator. */
TSNode bare = probe.signature[2];
ASSERT_TRUE(cbm_elixir_is_when_guard(bare));
TSNode bare_head = cbm_elixir_def_head_unwrap_guard(bare);
ASSERT_STR_EQ("identifier", ts_node_type(bare_head));
ASSERT_TRUE(head_probe_text_is(source, bare_head, "bare"));
ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare));
ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare_head));
/* Two guards on one clause. tree-sitter-elixir nests them
* RIGHT-associatively -- `when(twice(x), when(is_atom(x), is_binary(x)))`,
* which is what this asserts -- so the head is still the outer operator's
* left operand and the peel reaches it in one step. The repeated peel is
* therefore a fixed point rather than a chain walk on this grammar; the
* nodes that must answer true are the operator and the head. */
TSNode twice = probe.signature[3];
ASSERT_TRUE(cbm_elixir_is_when_guard(twice));
TSNode second_guard = ts_node_child_by_field_name(twice, TS_FIELD("right"));
ASSERT_TRUE(cbm_elixir_is_when_guard(second_guard));
TSNode twice_head = cbm_elixir_def_head_unwrap_guard(twice);
ASSERT_TRUE(head_probe_text_is(source, twice_head, "twice(x)"));
ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice));
ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice_head));
/* The second guard is a `when` operator too, and it is NOT on the head
* chain: a walk that treated any `when` above the head as a declaration
* would suppress the calls written inside it. */
ASSERT_FALSE(cbm_elixir_def_head_is(twice, second_guard));
/* An operator definition's head is a binary_operator too, and unwrapping it
* would name the function after its own left parameter. Only the `when`
* token admits the peel, so `def a + b` is returned unchanged and its left
* operand is not a head. */
TSNode op_def = probe.signature[4];
ASSERT_STR_EQ("binary_operator", ts_node_type(op_def));
ASSERT_FALSE(cbm_elixir_is_when_guard(op_def));
ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(op_def), op_def));
ASSERT_TRUE(cbm_elixir_def_head_is(op_def, op_def));
TSNode op_lhs = ts_node_child_by_field_name(op_def, TS_FIELD("left"));
ASSERT_TRUE(head_probe_text_is(source, op_lhs, "a"));
ASSERT_FALSE(cbm_elixir_def_head_is(op_def, op_lhs));
ts_tree_delete(tree);
ts_parser_delete(parser);
PASS();
}
/* `def name(args) when guard` parses its whole head as a `when`
* binary_operator, so the name sits on the operator's left operand rather than
* directly under the call. The def extractor accepted only `call` and
* `identifier` there, so every guarded clause was dropped -- and a function
* whose clauses ALL carry guards never reached the graph at all, silently.
* Guards are ordinary Elixir: on one real 2.9k-function codebase this hid 216
* of 270 all-guarded functions, and every call edge touching them. */
TEST(extract_elixir_guarded_def_head) {
CBMFileResult *r = extract("defmodule Guarded do\n"
" def plain(x), do: x\n"
" def guarded(x) when is_binary(x), do: x\n"
" def guarded(x) when is_list(x), do: x\n"
" def multi(x, y) when is_binary(x) and is_integer(y), do: {x, y}\n"
" defp priv_guarded(x) when is_atom(x), do: x\n"
" def bare when true, do: :ok\n"
" def a + b, do: {a, b}\n"
" def c - d when is_integer(c), do: {c, d}\n"
" def multi_guard(x) when is_atom(x) when is_binary(x), do: x\n"
"end\n",
CBM_LANG_ELIXIR, "t", "guarded.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT(has_def(r, "Function", "plain"));
ASSERT(has_def(r, "Function", "guarded"));
ASSERT(has_def(r, "Function", "multi"));
ASSERT(has_def(r, "Function", "priv_guarded"));
ASSERT(has_def(r, "Function", "bare"));
/* Elixir admits more than one guard on a clause. tree-sitter-elixir nests
* those `when` operators right-associatively, so the declared head stays
* the outermost operator's left operand -- one node either way, and the
* peel must not stop short of it or return the guard. */
ASSERT(has_def(r, "Function", "multi_guard"));
ASSERT_EQ(count_defs_named(r, "Function", "multi_guard"), 1);
/* An operator definition is a binary_operator head too, but it is not a
* guard: unwrapping it names the function after its own LEFT PARAMETER.
* `def a + b` must not mint a function called `a`, nor `def c - d when ...`
* one called `c` -- the guard's left operand is itself the `c - d`
* operator, which names no function either. Neither operator form is
* extracted at all, which is what this grammar did before guards were
* handled; only the `when` token admits the unwrap. */
ASSERT_EQ(count_defs_named(r, "Function", "a"), 0);
ASSERT_EQ(count_defs_named(r, "Function", "c"), 0);
cbm_free_result(r);
PASS();
}
/* A guard hides the head from the scope walk and from the calls walk.
* compute_elixir_func_qn accepts only a `call` or an `identifier` as a def's
* first argument, so a guarded clause resolved no QN and opened NO function
* scope: every call in its body was attributed to the FILE node, the function
* reported no outgoing edges, and its callees gained an in-edge from the file
* instead of from their caller. A paren-less guarded clause (`def f when g`) is
* worse still: it has no inner call node at all, so the guard operator itself
* falls through to extract_callee_name's first-identifier last resort and mints
* a phantom CALLS edge naming the function being defined. */
TEST(extract_elixir_guarded_def_head_scope) {
CBMFileResult *r = extract("defmodule Guards do\n"
" def target(x), do: x\n"
" def other(x), do: x\n"
" def unguarded_caller(a), do: target(a)\n"
" def guarded_caller(a) when is_binary(a) do\n"
" target(a)\n"
" other(a)\n"
" end\n"
" def bare_guarded when true, do: target(:ok)\n"
"end\n",
CBM_LANG_ELIXIR, "t", "guard_scope.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
/* A paren-less guarded head is a definition, never a call to itself. */
ASSERT_EQ(count_calls_named(r, "bare_guarded"), 0);
/* Both calls in the guarded body source to the guarded function, and the
* paren-less clause's body call to that clause. */
ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.guarded_caller"), 1);
ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope.guarded_caller"), 1);
ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.bare_guarded"), 1);
/* Control on the path the fix does not touch: an unguarded clause still
* sources its body call to itself. This is NOT a leak detector -- the walk
* expires scopes by tree depth (pop_expired_scopes), so a sibling clause
* always unwinds the previous one before pushing its own and "pushed but
* never popped" is not a state this walk can reach. */
ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.unguarded_caller"), 1);
/* Nothing falls back to the file node. */
ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope"), 0);
ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope"), 0);
cbm_free_result(r);
PASS();
}
/* The half the calls and scope assertions above cannot see: the usages walk
* asks whether an identifier sits inside the def's SIGNATURE, and treats
* everything that does as part of the binding rather than as a reference. A
* guard makes that signature the whole `when` operator, which spans the guard
* expression too -- so `a` read by `is_binary(a)` was classified as its own
* binding and emitted no USAGE row at all. A guard is an expression, not a
* pattern: the parameters are bound by `guarded(a, b)` and every mention of one
* to its right is a read, exactly as the same mention in the body already is.
* Unwrapping the signature to the head restores those rows without admitting
* the binding sites themselves, which is why each name is asserted both within
* its scope and in total. */
TEST(extract_elixir_guarded_def_head_guard_usages) {
CBMFileResult *r = extract("defmodule GuardUsage do\n"
" def plain(x), do: x\n"
" def guarded(a, b) when is_binary(a) and byte_size(a) > 3 do\n"
" b\n"
" end\n"
"end\n",
CBM_LANG_ELIXIR, "t", "guard_usage.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
/* Both guard reads of the parameter are usages of the guarded clause. */
ASSERT_EQ(count_usages_from(r, "a", "t.guard_usage.guarded"), 2);
/* ...and nothing else claims `a`: the `a` in `guarded(a, b)` binds it. */
ASSERT_EQ(count_usages_named(r, "a"), 2);
/* The body read is unchanged, and the head's `b` is still a binding --
* an unwrap that took the guard instead of the head would add both head
* parameters here. */
ASSERT_EQ(count_usages_from(r, "b", "t.guard_usage.guarded"), 1);
ASSERT_EQ(count_usages_named(r, "b"), 1);
/* Control: an unguarded clause was never affected either way. */
ASSERT_EQ(count_usages_from(r, "x", "t.guard_usage.plain"), 1);
ASSERT_EQ(count_usages_named(r, "x"), 1);
cbm_free_result(r);
PASS();
}
/* Every clause of an Elixir function is its own `def` call, and an Elixir QN
* carries neither module nor arity, so all clauses compute one qualified name
* and collide on a single graph node. cbm_gbuf_upsert_node ranks a same-QN
* collision by LARGEST start_line, so the survivor was the last clause in the
* file and get_code_snippet returned that clause as the whole function: a
* two-clause `admin?` read back as `def admin?(%__MODULE__{}), do: false` --
* the graph asserting a predicate that always returns false. The node has to
* span its own clauses, and ONLY its own: the fold is bounded to the
* immediately preceding definition and to the enclosing module body, so a def
* of another name ends the group and two same-named functions in two modules
* stay two nodes. */
TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module) {
CBMFileResult *r = extract("defmodule Roles do\n" /* 1 */
" def admin?(%{role: :admin}), do: true\n" /* 2 */
" def admin?(%{}), do: false\n" /* 3 */
"\n" /* 4 */
" def promote(user), do: user\n" /* 5 */
"\n" /* 6 */
" def admin?(_other), do: false\n" /* 7 */
"end\n" /* 8 */
"\n" /* 9 */
"defmodule Guests do\n" /* 10 */
" def admin?(:root), do: true\n" /* 11 */
" def admin?(_any), do: false\n" /* 12 */
"end\n", /* 13 */
CBM_LANG_ELIXIR, "t", "roles.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
/* The two adjacent Roles clauses fold; the clause past `promote` is its own
* group; the Guests pair is a DIFFERENT function and stays its own node. */
ASSERT_EQ(3, count_defs_named(r, "Function", "admin?"));
ASSERT_EQ(1, count_defs_named(r, "Function", "promote"));
const CBMDefinition *admin[3] = {NULL, NULL, NULL};
const CBMDefinition *promote = NULL;
int seen = 0;
for (int i = 0; i < r->defs.count; i++) {
if (strcmp(r->defs.items[i].label, "Function") != 0) {
continue;
}
if (strcmp(r->defs.items[i].name, "admin?") == 0 && seen < 3) {
admin[seen++] = &r->defs.items[i];
} else if (strcmp(r->defs.items[i].name, "promote") == 0) {
promote = &r->defs.items[i];
}
}
ASSERT_NOT_NULL(admin[0]);
ASSERT_NOT_NULL(admin[1]);
ASSERT_NOT_NULL(admin[2]);
ASSERT_NOT_NULL(promote);
ASSERT_EQ(2, (int)admin[0]->start_line);
ASSERT_EQ(3, (int)admin[0]->end_line);
/* separated from lines 2-3 by `promote`, so it keeps its own span */
ASSERT_EQ(7, (int)admin[1]->start_line);
ASSERT_EQ(7, (int)admin[1]->end_line);
/* Guests.admin? never reaches back over the module boundary */
ASSERT_EQ(11, (int)admin[2]->start_line);
ASSERT_EQ(12, (int)admin[2]->end_line);
ASSERT_EQ(5, (int)promote->start_line);
ASSERT_EQ(5, (int)promote->end_line);
cbm_free_result(r);
PASS();
}
/* A nested module is the one place where the "def of another name ends the
* group" rule is not enough: an Elixir QN carries no module, so `Outer.run` and
* `Outer.Inner.run` compute the SAME qualified name, and when Inner sits
* directly above Outer's own clause the two are adjacent in the extracted defs.
* The fold is bounded to the module body as well, so they stay two nodes. */
TEST(extract_elixir_nested_module_clauses_do_not_fold) {
CBMFileResult *r = extract("defmodule Outer do\n" /* 1 */
" defmodule Inner do\n" /* 2 */
" def run(a), do: a\n" /* 3 */
" end\n" /* 4 */
"\n" /* 5 */
" def run(b), do: b + 1\n" /* 6 */
"end\n", /* 7 */
CBM_LANG_ELIXIR, "t", "nested.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(2, count_defs_named(r, "Function", "run"));
const CBMDefinition *runs[2] = {NULL, NULL};
int seen = 0;
for (int i = 0; i < r->defs.count; i++) {
if (strcmp(r->defs.items[i].label, "Function") == 0 &&
strcmp(r->defs.items[i].name, "run") == 0 && seen < 2) {
runs[seen++] = &r->defs.items[i];
}
}
ASSERT_NOT_NULL(runs[0]);
ASSERT_NOT_NULL(runs[1]);
ASSERT_EQ(3, (int)runs[0]->start_line);
ASSERT_EQ(3, (int)runs[0]->end_line);
ASSERT_EQ(6, (int)runs[1]->start_line);
ASSERT_EQ(6, (int)runs[1]->end_line);
cbm_free_result(r);
PASS();
}
/* Folding clauses has to decide is_exported for the merged node. A `def` clause
* and a `defp` clause of the same name AND arity are clauses of one function --
* a private guard clause or a nil short-circuit written above the public head
* is ordinary Elixir -- so is_exported is the OR over the folded clauses: the
* name is callable from outside if any clause exports it, and a wholly private
* function stays unexported. */
TEST(extract_elixir_folded_clause_is_exported_is_the_or) {
CBMFileResult *r = extract("defmodule Acc do\n" /* 1 */
" def build(l), do: build(l)\n" /* 2 */
" defp build([]), do: []\n" /* 3 */
"\n" /* 4 */
" defp trim(nil), do: nil\n" /* 5 */
" defp trim(s), do: s\n" /* 6 */
"end\n", /* 7 */
CBM_LANG_ELIXIR, "t", "acc.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(1, count_defs_named(r, "Function", "build"));
ASSERT_EQ(1, count_defs_named(r, "Function", "trim"));
const CBMDefinition *build = NULL;
const CBMDefinition *trim = NULL;
for (int i = 0; i < r->defs.count; i++) {
if (strcmp(r->defs.items[i].label, "Function") != 0) {
continue;
}
if (strcmp(r->defs.items[i].name, "build") == 0) {
build = &r->defs.items[i];
} else if (strcmp(r->defs.items[i].name, "trim") == 0) {
trim = &r->defs.items[i];
}
}
ASSERT_NOT_NULL(build);
ASSERT_NOT_NULL(trim);
ASSERT_EQ(2, (int)build->start_line);
ASSERT_EQ(3, (int)build->end_line);
ASSERT_TRUE(build->is_exported);
ASSERT_EQ(5, (int)trim->start_line);
ASSERT_EQ(6, (int)trim->end_line);
ASSERT_FALSE(trim->is_exported);
cbm_free_result(r);
/* The other direction, and the only one the `prev->is_exported ||` disjunct
* decides. Above, the exporting clause is the FIRST one, so a plain
* `prev->is_exported = is_exported` assignment would also have to lose it
* on the later defp -- but a last-clause-wins assignment happens to be
* right whenever the def comes last. Here the defp comes first and the def
* second, so the surviving flag can only be true if the fold ORs rather
* than assigns. */
CBMFileResult *o = extract("defmodule Ord do\n" /* 1 */
" defp z(1), do: 1\n" /* 2 */
" def z(2), do: 2\n" /* 3 */
"end\n", /* 4 */
CBM_LANG_ELIXIR, "t", "ord.ex");
ASSERT_NOT_NULL(o);
ASSERT_FALSE(o->has_error);
ASSERT_EQ(1, count_defs_named(o, "Function", "z"));
const CBMDefinition *z = NULL;
for (int i = 0; i < o->defs.count; i++) {
if (strcmp(o->defs.items[i].label, "Function") == 0 &&
strcmp(o->defs.items[i].name, "z") == 0) {
z = &o->defs.items[i];
}
}
ASSERT_NOT_NULL(z);
ASSERT_EQ(2, (int)z->start_line);
ASSERT_EQ(3, (int)z->end_line);
ASSERT_TRUE(z->is_exported);
cbm_free_result(o);
PASS();
}
/* First definition with this name, for the Elixir clause-fold tests below.
* find_def() is declared much further down this file. */
static const CBMDefinition *elixir_first_def(CBMFileResult *r, const char *name) {
for (int i = 0; i < r->defs.count; i++) {
if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0) {
return &r->defs.items[i];
}
}
return NULL;
}
/* A guarded clause head is a DECLARATION of the name, never a call to it, and
* the call extractor has to agree with the def extractor about which node the
* head is. It did not: it suppressed a head only when the head node IS the
* def's first named argument, but `def f(x) when g` parses its whole head as a
* `when` binary_operator, so the inner `f(x)` was not that argument and was
* recorded as a call to `f`. Under the folded span that phantom lands inside
* the function's own node, and self-recursion is decided by line containment,
* so an ordinary two-clause guarded function reported itself recursive.
* Recursion is a load-bearing signal: `recursive` is a queryable node property
* and seeds the cycle detection in pass_complexity. */
TEST(extract_elixir_guarded_clause_head_is_not_a_self_call) {
CBMFileResult *r = extract("defmodule Guarded do\n" /* 1 */
" def f(x) when is_integer(x), do: x\n" /* 2 */
" def f(_x), do: 0\n" /* 3 */
"\n" /* 4 */
" def solo(x) when is_binary(x), do: x\n" /* 5 */
"end\n", /* 6 */
CBM_LANG_ELIXIR, "t", "guard_call.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
/* No call to either name exists anywhere in this file. */
ASSERT_EQ(0, count_calls_named(r, "f"));
ASSERT_EQ(0, count_calls_named(r, "solo"));
/* The guard predicate IS a real call and stays one. */
ASSERT_EQ(1, count_calls_named(r, "is_integer"));
ASSERT_EQ(1, count_calls_named(r, "is_binary"));
ASSERT_EQ(1, count_defs_named(r, "Function", "f"));
const CBMDefinition *f = elixir_first_def(r, "f");
ASSERT_NOT_NULL(f);
ASSERT_EQ(2, (int)f->start_line);
ASSERT_EQ(3, (int)f->end_line);
ASSERT_FALSE(f->is_recursive);
/* A single guarded clause was wrong even before the span widened: the
* phantom landed on its own one-line node. */
const CBMDefinition *solo = elixir_first_def(r, "solo");
ASSERT_NOT_NULL(solo);
ASSERT_FALSE(solo->is_recursive);
cbm_free_result(r);
PASS();
}
/* Every reference of any kind this file records for `name`, across all four
* extractors the unified walk runs over one subtree: a call site, a value
* usage, a read/write occurrence, a type reference. Suppressing a node in ONE
* of them does not remove a phantom reference, it moves it -- handle_calls
* declining a node leaves state->callee_expr unset, and handle_usages then
* reaches the bare identifier and mints a USAGE instead. USAGE and CALLS are
* separate rows in the edge table, so the relabelled phantom also escapes the
* UNIQUE(source,target,type) dedup that had been collapsing it onto a real
* call, and pass_importance counts an inbound reference that did not exist
* before. An assertion naming only r->calls cannot see any of that. */
static int elixir_any_ref_count(CBMFileResult *r, const char *name) {
int count = count_calls_named(r, name);
for (int i = 0; i < r->usages.count; i++) {
if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, name) == 0) {
count++;
}
}
for (int i = 0; i < r->rw.count; i++) {
if (r->rw.items[i].var_name && strcmp(r->rw.items[i].var_name, name) == 0) {
count++;
}
}
for (int i = 0; i < r->type_refs.count; i++) {
if (r->type_refs.items[i].type_name && strcmp(r->type_refs.items[i].type_name, name) == 0) {
count++;
}
}
return count;
}
/* The head suppression has to REMOVE the phantom, not relabel it. What makes
* that hold for a def head is not the calls walk alone: is_elixir_def_binding in
* extract_usages.c already treats everything inside a def's first argument as
* a binding occurrence rather than a reference, so the identifier handle_calls
* declines is declined by handle_usages too. That agreement is the invariant
* under test, and it is why the counts below are over every extractor rather
* than over r->calls.
*
* `spec_subject` pins the boundary between the two mechanisms. A typespec
* subject is the same shape as a def head, and suppressing it in the calls walk
* would only relabel its phantom as a USAGE, because nothing treats it as a
* binding. It is removed instead by skipping the whole `@spec` subtree before
* any extractor sees it (is_elixir_typespec_attribute, extract_unified.c), so
* the assertion is both that no reference of any kind survives AND that what
* does survive is never a relabelling: the equality holds at 0 == 0 here, and
* would hold at 1 == 1 on a build with the subtree skip removed but the calls
* suppression left in place. */
TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind) {
CBMFileResult *r = extract("defmodule Heads do\n" /* 1 */
" def guarded(x) when is_integer(x), do: x\n" /* 2 */
" def guarded(_x), do: 0\n" /* 3 */
"\n" /* 4 */
" defp bare(x), do: x\n" /* 5 */
"\n" /* 6 */
" @spec spec_subject(integer) :: integer\n" /* 7 */
" def spec_subject(n), do: n\n" /* 8 */
"end\n", /* 9 */
CBM_LANG_ELIXIR, "t", "heads.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
/* Nothing in this file references `guarded` or `bare`. Not as a call, not
* as a value, not as a read/write, not as a type. */
ASSERT_EQ(0, elixir_any_ref_count(r, "guarded"));
ASSERT_EQ(0, elixir_any_ref_count(r, "bare"));
/* The guard predicate is a real call and survives as one. */
ASSERT_EQ(1, count_calls_named(r, "is_integer"));
ASSERT_EQ(1, elixir_any_ref_count(r, "is_integer"));
/* Neither the @spec line nor the def head it precedes leaves a reference,
* and nothing has been relabelled into a usage, a read/write or a type
* reference. */
ASSERT_EQ(0, count_calls_named(r, "spec_subject"));
ASSERT_EQ(count_calls_named(r, "spec_subject"),
elixir_any_ref_count(r, "spec_subject"));
ASSERT_EQ(1, count_defs_named(r, "Function", "guarded"));
const CBMDefinition *guarded = elixir_first_def(r, "guarded");
ASSERT_NOT_NULL(guarded);
ASSERT_EQ(2, (int)guarded->start_line);
ASSERT_EQ(3, (int)guarded->end_line);
ASSERT_FALSE(guarded->is_recursive);
cbm_free_result(r);
PASS();
}
/* The other direction: suppressing declaration heads must not blind the
* detector to real recursion, and folding must not lose a self-call written in
* a clause that is no longer the surviving one. `walk` recurses from its
* guarded second clause; exactly one call to `walk` is recorded (the body one,
* not the two heads) and the folded node is recursive. */
TEST(extract_elixir_real_recursion_survives_head_suppression) {
CBMFileResult *r = extract("defmodule Rec do\n" /* 1 */
" def walk([]), do: []\n" /* 2 */
" def walk([h | t]) when is_integer(h), do: [h | walk(t)]\n" /* 3 */
"end\n", /* 4 */
CBM_LANG_ELIXIR, "t", "rec.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(1, count_calls_named(r, "walk"));
ASSERT_EQ(1, count_defs_named(r, "Function", "walk"));
const CBMDefinition *walk = elixir_first_def(r, "walk");
ASSERT_NOT_NULL(walk);
ASSERT_EQ(2, (int)walk->start_line);
ASSERT_EQ(3, (int)walk->end_line);
ASSERT_TRUE(walk->is_recursive);
cbm_free_result(r);
PASS();
}
/* An Elixir module attribute parses as `unary_operator(@, call(<attr>, args))`,
* so a typespec line reaches the unified walk as ordinary code and every symbol
* it names collects a phantom inbound edge from the enclosing Module -- a CALLS
* onto the specified function, and for `@type` a USAGE plus a WRITES asserting
* a mutation that does not exist. On a codebase whose convention is @spec on
* every public function, fan_in then never reaches 0 and "which exported
* functions nothing calls" is unanswerable.
*
* All six heads in the skip list are exercised, so deleting any one entry from
* typespec_heads[] breaks this test.
*
* Two of the groups below pin a decision rather than a repair, and both are
* argued in is_elixir_typespec_attribute's comment:
*
* type_refs[] -- a typespec's type references are not indexed at all. A type
* declaration mints no node, so such a reference resolves onto nothing or
* onto a same-named FUNCTION, which is the pollution rather than a record of
* it. Restoring any of them must break this test.
*
* behaviour_contract_names[] -- accepted cost. A @callback / @macrocallback
* name is a function name, so before this change it resolved cross-file onto
* the functions implementing the behaviour. Nothing replaces that edge.
*
* The attribute NAME phantom outside the typespec family is deliberately still
* there: `@timeout` keeps its one reference, and its VALUE is ordinary
* compile-time code that still runs. `guarded` is pinned at 0 rather than 1
* because the guarded def head no longer mints a phantom either -- the calls
* walk now recognises a `when`-wrapped head as a declaration. */
TEST(extract_elixir_typespec_attribute_is_not_code) {
/* Names a typespec declares, plus the six attribute heads themselves.
* Every one carries a reference on the pristine build. */
static const char *const declared[] = {"with_spec", "entry", "secret", "handle",
"spec", "type", "typep", "opaque",
"callback", "macrocallback", NULL};
/* Accepted cost: these used to resolve onto the behaviour's implementors. */
static const char *const behaviour_contract_names[] = {"handle_it", "expand_it", NULL};
/* Pinned decision: type references are not indexed -- remote, bare-local
* and builtin alike. */
static const char *const type_refs[] = {"MyApp.User.t", "String.t", "MyApp.Vault.key",
"Macro.t", "t", "key",
"term", "integer", "reference",
"atom", NULL};
CBMFileResult *r = extract("defmodule Specced do\n"
" @timeout Application.compile_env(:app, :timeout)\n"
" @type entry :: String.t()\n"
" @typep secret :: MyApp.Vault.key()\n"
" @opaque handle :: reference()\n"
" @callback handle_it(term) :: :ok\n"
" @macrocallback expand_it(term) :: Macro.t()\n"
" @spec with_spec(MyApp.User.t()) :: integer\n"
" def with_spec(n), do: n\n"
" def without_spec(n), do: n\n"
" @spec guarded(atom) :: :ok\n"
" def guarded(x) when is_atom(x), do: :ok\n"
"end\n",
CBM_LANG_ELIXIR, "t", "specced.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
/* The definitions themselves are a separate pass and are unaffected. */
ASSERT(has_def(r, "Function", "with_spec"));
ASSERT(has_def(r, "Function", "without_spec"));
ASSERT(has_def(r, "Function", "guarded"));
/* Control on the instrument, not a pin: `without_spec` carries no typespec
* and is never called, so it reads 0 on the pristine build too. It is here
* to show elixir_any_ref_count does not score a definition as a reference,
* which is what would make every 0 below vacuous. It cannot fail first and
* cannot catch a regression. */
ASSERT_EQ(0, elixir_any_ref_count(r, "without_spec"));
for (const char *const *name = declared; *name; name++) {
ASSERT_EQ(0, elixir_any_ref_count(r, *name));
}
for (const char *const *name = behaviour_contract_names; *name; name++) {
ASSERT_EQ(0, elixir_any_ref_count(r, *name));
}
for (const char *const *name = type_refs; *name; name++) {
ASSERT_EQ(0, elixir_any_ref_count(r, *name));
}
/* A non-typespec attribute is untouched: its value really is compile-time
* code, and its name still mints the one phantom this fix does not close. */
ASSERT_EQ(1, count_calls_named(r, "Application.compile_env"));
ASSERT_EQ(1, elixir_any_ref_count(r, "timeout"));
/* Both phantoms onto `guarded` are gone: the @spec one to the subtree skip,
* the def-head one to the calls walk recognising a guarded head. */
ASSERT_EQ(0, elixir_any_ref_count(r, "guarded"));
cbm_free_result(r);
PASS();
}
/* tree-sitter-elixir gives a call's arguments node no field name, so the
* generic `arguments` field lookup returns null and first_string_arg was never
* populated for any Elixir call — Phoenix route paths, service URLs and config
@@ -13762,7 +14527,18 @@ SUITE(extraction) {
/* Functional */
RUN_TEST(elixir_function);
RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it);
RUN_TEST(extract_elixir_guarded_def_head);
RUN_TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module);
RUN_TEST(extract_elixir_nested_module_clauses_do_not_fold);
RUN_TEST(extract_elixir_folded_clause_is_exported_is_the_or);
RUN_TEST(extract_elixir_guarded_clause_head_is_not_a_self_call);
RUN_TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind);
RUN_TEST(extract_elixir_real_recursion_survives_head_suppression);
RUN_TEST(extract_elixir_typespec_attribute_is_not_code);
RUN_TEST(elixir_call_string_argument);
RUN_TEST(extract_elixir_guarded_def_head_scope);
RUN_TEST(extract_elixir_guarded_def_head_guard_usages);
RUN_TEST(elixir_channels_pubsub_and_handle_in);
RUN_TEST(haskell_function);
RUN_TEST(haskell_infix_definition_name);
+19
View File
@@ -173,6 +173,13 @@ TEST(infrascan_service_pattern_match_rejects_ids_inside_words) {
{"kafkaesque.story.tell", CBM_SVC_NONE},
/* "Axios" glued to a lowercase continuation */
{"geo.Axiosphere.map", CBM_SVC_NONE},
/* Angular receiver rows end in a dot (#2768): a longer receiver that
* only starts with the same letters is not the HttpClient (NestJS
* route registration, AngularJS test double) */
{"this.httpAdapter.get", CBM_SVC_NONE},
{"this.httpServer.post", CBM_SVC_NONE},
{"$httpBackend.expectGET", CBM_SVC_NONE},
{"this.httpAdapter\n .get", CBM_SVC_NONE},
{NULL, CBM_SVC_NONE},
};
ASSERT_EQ(svc_case_mismatches(cases), 0);
@@ -202,6 +209,18 @@ TEST(infrascan_service_pattern_match_keeps_real_library_qns) {
{"Axios.post", CBM_SVC_HTTP},
{"AxiosInstance.get", CBM_SVC_HTTP},
{"curl_exec", CBM_SVC_HTTP},
/* Angular HttpClient receivers (#2768); `_httpClient` is the common
* C# field spelling of the same client */
{"this.http.get", CBM_SVC_HTTP},
{"this.httpClient.post", CBM_SVC_HTTP},
{"httpClient.put", CBM_SVC_HTTP},
{"_httpClient.GetAsync", CBM_SVC_HTTP},
{"$http.get", CBM_SVC_HTTP},
/* a chain wrapped before the verb keeps the line break (LF / CRLF) */
{"this.http\n .post", CBM_SVC_HTTP},
{"this.http\r\n .delete", CBM_SVC_HTTP},
{"this.httpClient\n .put", CBM_SVC_HTTP},
{"$http\n .get", CBM_SVC_HTTP},
{"surf.get", CBM_SVC_HTTP},
{"hyper.Client.request", CBM_SVC_HTTP},
{"phin.promisified", CBM_SVC_HTTP},
+211
View File
@@ -10227,6 +10227,57 @@ TEST(tool_delete_project_mutation_guard_blocks_then_releases) {
PASS();
}
/* delete_project removes the ADR sidecar with the project, and a re-index of a
* same-named project afterwards starts with no ADR (round-2 review item 7c). */
TEST(tool_delete_project_removes_adr_sidecar) {
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-adr-XXXXXX");
if (!cbm_mkdtemp(cache)) {
PASS();
}
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
const char *project = "adr-delete-project";
char db_path[CBM_SZ_1K];
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
cbm_store_t *setup = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(setup);
ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/adr-delete-project"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_store(setup, project, "# Decision\nkeep it"), CBM_STORE_OK);
cbm_store_close(setup);
char adr_path[CBM_SZ_1K];
snprintf(adr_path, sizeof(adr_path), "%s.adr.db", db_path);
ASSERT_TRUE(cbm_file_exists(adr_path));
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"adr-delete-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "deleted"));
free(resp);
ASSERT_FALSE(cbm_file_exists(db_path));
ASSERT_FALSE(cbm_file_exists(adr_path)); /* sidecar removed with the project */
/* A fresh store of the same name starts with no ADR. */
cbm_store_t *fresh = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(fresh);
cbm_adr_t adr = {0};
ASSERT_EQ(cbm_store_adr_get(fresh, project, &adr), CBM_STORE_NOT_FOUND);
cbm_store_adr_free(&adr);
cbm_store_close(fresh);
cbm_mcp_server_free(srv);
cleanup_project_db(cache, project);
cbm_rmdir(cache);
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
PASS();
}
TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) {
char root[CBM_SZ_1K];
(void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir());
@@ -24385,6 +24436,161 @@ TEST(autoindex_limit_guards_git_root_issue713) {
PASS();
}
/* A servable project row is a committed index. user_version stays 0, so a
* later run cannot take the incremental no-op and skip recording workers. */
static bool mcp_publish_committed_index(const char *root) {
char *project = root ? cbm_project_name_from_path(root) : NULL;
cbm_store_t *store = project ? cbm_store_open(project) : NULL;
bool published = store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK;
cbm_store_close(store);
free(project);
return published;
}
#ifdef CBM_ENABLE_TEST_SEAMS
typedef struct {
const char *root;
bool published;
} mcp_committed_seed_t;
/* maybe_auto_index returns before the thread when <project>.db already
* exists. Publish the committed index from the count hook, which runs after
* that check and before autoindex_thread chooses a width. */
static void mcp_autoindex_publish_committed(void *context) {
mcp_committed_seed_t *seed = context;
if (seed) {
seed->published = mcp_publish_committed_index(seed->root);
}
}
#endif
/* First index, including one started automatically, uses every core. Headroom
* applies only once a servable project database already exists.
* automatic: in-process auto-index (initialize → autoindex_thread).
* Otherwise index_repository with _cbm_background, the daemon/watcher path. */
static int mcp_assert_inprocess_worker_policy(bool committed, bool automatic) {
char cache[256];
char repo[512];
(void)snprintf(cache, sizeof(cache), "%s/cbm-worker-policy-%d%d-XXXXXX", cbm_tmpdir(),
committed ? 1 : 0, automatic ? 1 : 0);
bool cache_ready = cbm_mkdtemp(cache) != NULL;
(void)snprintf(repo, sizeof(repo), "%s/repo", cache);
char source[640];
(void)snprintf(source, sizeof(source), "%s/main.py", repo);
bool repo_ready = cache_ready && th_mkdir_p(repo) == 0 &&
th_write_file(source, "def background_index():\n return True\n") == 0;
mcp_test_env_backup_t environment[] = {
{.name = "CBM_CACHE_DIR"},
{.name = "CBM_WORKERS"},
{.name = "CBM_INDEX_SINGLE_THREAD"},
};
bool environment_saved = true;
for (size_t i = 0; i < sizeof(environment) / sizeof(environment[0]); i++) {
const char *value = getenv(environment[i].name);
environment[i].present = value != NULL;
environment[i].value = value ? strdup(value) : NULL;
environment_saved = environment_saved && (!value || environment[i].value);
}
bool environment_ready = environment_saved && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 &&
cbm_unsetenv("CBM_WORKERS") == 0 &&
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD") == 0;
bool seeded = !committed;
char old_cwd[CBM_SZ_4K] = {0};
bool cwd_ready = true;
cbm_config_t *config = NULL;
bool config_ready = true;
cbm_mcp_server_t *server = NULL;
char *response = NULL;
if (automatic) {
cwd_ready = repo_ready && environment_ready && cbm_getcwd(old_cwd, sizeof(old_cwd)) &&
cbm_chdir(repo) == 0;
config = cwd_ready ? cbm_config_open(cache) : NULL;
config_ready = config && cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "true") == 0 &&
cbm_config_set(config, CBM_CONFIG_AUTO_WATCH, "false") == 0;
cbm_pipeline_worker_count_test_reset();
server = config_ready ? cbm_mcp_server_new(NULL) : NULL;
if (server) {
cbm_mcp_server_set_config(server, config);
#ifdef CBM_ENABLE_TEST_SEAMS
mcp_committed_seed_t seed = {.root = repo, .published = !committed};
if (committed) {
cbm_mcp_server_set_auto_index_count_test_hook(
server, mcp_autoindex_publish_committed, &seed);
}
#endif
response = cbm_mcp_server_handle(
server, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}");
#ifdef CBM_ENABLE_TEST_SEAMS
seeded = seed.published;
#endif
cbm_mcp_server_free(server); /* joins the automatic index thread */
}
} else {
if (committed && repo_ready && environment_ready) {
seeded = mcp_publish_committed_index(repo);
}
cbm_pipeline_worker_count_test_reset();
server = repo_ready && environment_ready && seeded ? cbm_mcp_server_new(NULL) : NULL;
if (server) {
char repo_json[512];
char args[CBM_SZ_1K];
(void)snprintf(repo_json, sizeof(repo_json), "%s", repo);
cbm_normalize_path_sep(repo_json);
(void)snprintf(args, sizeof(args), "{\"_cbm_background\":true,\"repo_path\":\"%s\"}",
repo_json);
response = cbm_mcp_handle_tool(server, "index_repository", args);
cbm_mcp_server_free(server);
}
}
int selected_workers = cbm_pipeline_worker_count_test_last();
bool server_ready = server != NULL;
bool response_ready = response != NULL;
free(response);
cbm_config_close(config);
if (automatic && cwd_ready) {
(void)cbm_chdir(old_cwd);
}
/* Capture while CBM_WORKERS is still unset. Restoring first makes the
* expected count follow the lane override instead of this policy. */
int expected = committed ? cbm_pipeline_background_worker_width(cbm_default_worker_count(true),
cbm_default_worker_count(false))
: cbm_default_worker_count(true);
mcp_test_restore_env(environment, sizeof(environment) / sizeof(environment[0]));
bool cleaned = !cache_ready || th_rmtree(cache) == 0;
ASSERT_TRUE(cache_ready);
ASSERT_TRUE(repo_ready);
ASSERT_TRUE(environment_saved);
ASSERT_TRUE(environment_ready);
ASSERT_TRUE(cwd_ready);
ASSERT_TRUE(config_ready);
ASSERT_TRUE(seeded);
ASSERT_TRUE(server_ready);
ASSERT_TRUE(response_ready);
ASSERT_EQ(selected_workers, expected);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(mcp_auto_index_in_process_fresh_project_uses_full_width) {
return mcp_assert_inprocess_worker_policy(false, true);
}
TEST(mcp_auto_index_in_process_existing_index_keeps_headroom) {
return mcp_assert_inprocess_worker_policy(true, true);
}
TEST(mcp_background_flag_fresh_project_uses_full_width) {
return mcp_assert_inprocess_worker_policy(false, false);
}
TEST(mcp_background_flag_existing_index_keeps_headroom) {
return mcp_assert_inprocess_worker_policy(true, false);
}
/* ══════════════════════════════════════════════════════════════════
* #853 — auto_watch=false must ALSO gate the SUPERVISED fresh-index
* watcher registration (keystone × #849 merge interaction)
@@ -26736,6 +26942,10 @@ SUITE(mcp) {
RUN_TEST(autoindex_limit_guards_non_git_root_issue713);
RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713);
RUN_TEST(autoindex_limit_guards_git_root_issue713);
RUN_TEST(mcp_auto_index_in_process_fresh_project_uses_full_width);
RUN_TEST(mcp_auto_index_in_process_existing_index_keeps_headroom);
RUN_TEST(mcp_background_flag_fresh_project_uses_full_width);
RUN_TEST(mcp_background_flag_existing_index_keeps_headroom);
RUN_TEST(search_code_context_window_is_bounded);
RUN_TEST(search_code_context_extreme_values_answer_normally);
RUN_TEST(tool_query_graph_max_rows_above_ceiling_is_capped);
@@ -26757,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);
+307
View File
@@ -3026,6 +3026,311 @@ TEST(parallel_csharp_same_name_class_binds_by_namespace) {
PASS();
}
/* #2120 residual, reported on a real repo: step 8b binds only namespace-
* qualified TOP-LEVEL types. A qualified `A.B` whose qualifier A is a TYPE
* (nested-type constructor `new Command.Result(...)`, chained-constructor
* receiver `new Grain.Handler(dep).Handle(...)`) and a top-level type with NO
* namespace declaration (global namespace, e.g. a `[Inject]`-consumed service)
* both fell through to the short-name fallback, where the prefix score against
* a path-derived QN is 0 for every variant — registration order picked the
* target. Decoy files are registered FIRST so the old registry-order behavior
* binds every check to the decoy and the test fails. */
TEST(parallel_csharp_nested_type_and_global_namespace_binding) {
static const char decoy_src[] =
"namespace Acme.Layouts.Cqrs;\n"
"public class GrainCommand {\n"
" public class Handler {\n"
" public int Handle() => 9;\n"
" }\n"
" public class Result {\n"
" public Result(bool ok) { }\n"
" public int Code() => 9;\n"
" }\n"
"}\n";
static const char partner_src[] =
"namespace Acme.Partner.Cqrs;\n"
"public class GrainCommand {\n"
" public class Handler {\n"
" private readonly int _v;\n"
" public Handler(int v) { _v = v; }\n"
" public int Handle() => _v;\n"
" }\n"
" public class Result {\n"
" public Result(bool ok) { }\n"
" public int Code() => 1;\n"
" }\n"
"}\n";
static const char backoffice_src[] =
"namespace Acme.BackOffice.Cqrs;\n"
"public class GrainCommand {\n"
" public class Result {\n"
" public Result(bool ok) { }\n"
" public int Code() => 2;\n"
" }\n"
"}\n";
static const char nested_user_src[] =
"using Acme.Partner.Cqrs;\n"
"namespace Acme.App;\n"
"public class NestedUser {\n"
" public int Run() {\n"
" var r = new GrainCommand.Result(true);\n"
" return r.Code();\n"
" }\n"
"}\n";
static const char chained_user_src[] =
"using Acme.Partner.Cqrs;\n"
"namespace Acme.App;\n"
"public class ChainedUser {\n"
" public int Run() {\n"
" return new GrainCommand.Handler(3).Handle();\n"
" }\n"
"}\n";
static const char other_update_src[] =
"namespace Acme.Other.Web.Services;\n"
"public class UpdateService {\n"
" public string Check() => \"other\";\n"
"}\n";
/* No namespace declaration: the global namespace. */
static const char global_update_src[] =
"public class UpdateService {\n"
" public string Check() => \"global\";\n"
"}\n";
static const char boot_src[] =
"namespace Acme.App;\n"
"public class Boot {\n"
" public string Boot1() {\n"
" var u = new UpdateService();\n"
" return u.Check();\n"
" }\n"
"}\n";
/* The lookup-order probes: the SAME simple name is importable from
* Acme.Other.Web.Services AND declared in the global namespace. C# looks
* a simple name up namespace by namespace, innermost outward; at each one
* it checks that namespace's members, then the usings of the namespace
* declaration enclosing the reference. The global namespace comes last,
* and its "declaration" is the compilation unit, so:
* - a compilation-unit `using` (ImportUser) is consulted only AFTER the
* global namespace's members: the global UpdateService wins;
* - a `using` inside the namespace body (NsUsingUser) is consulted
* before any outer namespace: the imported UpdateService wins. */
static const char import_user_src[] =
"using Acme.Other.Web.Services;\n"
"namespace Acme.App;\n"
"public class ImportUser {\n"
" public string Run2() {\n"
" var u = new UpdateService();\n"
" return u.Check();\n"
" }\n"
"}\n";
static const char ns_using_user_src[] =
"namespace Acme.App {\n"
" using Acme.Other.Web.Services;\n"
" public class NsUsingUser {\n"
" public string Run3() {\n"
" var u = new UpdateService();\n"
" return u.Check();\n"
" }\n"
" }\n"
"}\n";
const char *project = "cbm_cs_nested";
static const char *rels[] = {
"src/Decoy/Layouts.cs",
"src/Decoy/OtherUpdate.cs",
"src/Partner/Cqrs/GrainCommand.cs",
"src/BackOffice/Cqrs/GrainCommand.cs",
"src/Globals/UpdateService.cs",
"src/App/NestedUser.cs",
"src/App/ChainedUser.cs",
"src/App/Boot.cs",
"src/App/ImportUser.cs",
"src/App/NsUsingUser.cs",
};
const char *srcs[] = {decoy_src, other_update_src, partner_src,
backoffice_src, global_update_src, nested_user_src,
chained_user_src, boot_src, import_user_src,
ns_using_user_src};
enum { N_FILES = 10, N_CHECKS = 12 };
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_nested_XXXXXX");
if (!cbm_mkdtemp(tmpdir)) {
FAIL("mkdtemp failed");
}
char paths[N_FILES][512];
cbm_file_info_t files[N_FILES] = {0};
for (int i = 0; i < N_FILES; i++) {
snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]);
if (th_write_file(paths[i], srcs[i]) != 0) {
th_rmtree(tmpdir);
FAIL("failed to write C# nested-type fixture");
}
files[i].path = paths[i];
files[i].rel_path = (char *)rels[i];
files[i].language = CBM_LANG_CSHARP;
}
cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL};
g_harness_cs_registry = true;
graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2);
g_harness_cs_registry = false;
ASSERT_NOT_NULL(graphs[0]);
ASSERT_NOT_NULL(graphs[1]);
const char *pf_code = "cbm_cs_nested.src.Partner.Cqrs.GrainCommand.GrainCommand."
"Result.Code";
const char *pf_handle = "cbm_cs_nested.src.Partner.Cqrs.GrainCommand.GrainCommand."
"Handler.Handle";
const char *bo_code = "cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand."
"Result.Code";
const char *decoy_code = "cbm_cs_nested.src.Decoy.Layouts.GrainCommand.GrainCommand."
"Result.Code";
const char *decoy_handle = "cbm_cs_nested.src.Decoy.Layouts.GrainCommand.GrainCommand."
"Handler.Handle";
const char *global_check = "cbm_cs_nested.src.Globals.UpdateService.UpdateService.Check";
const char *other_check = "cbm_cs_nested.src.Decoy.OtherUpdate.UpdateService.Check";
bool ok[2][N_CHECKS];
bool all = true;
for (int g = 0; g < 2; g++) {
cbm_gbuf_t *gb = graphs[g];
ok[g][0] = has_edge_from_callable_to_qn(gb, "NestedUser.Run", pf_code, "CALLS");
ok[g][1] = !has_edge_from_callable_to_qn(gb, "NestedUser.Run", bo_code, "CALLS") &&
!has_edge_from_callable_to_qn(gb, "NestedUser.Run", decoy_code, "CALLS");
/* The ctor EDGE itself joins through the pipeline's textual carrier and
* is out of scope here (see PR notes); what must hold is that the
* nested type RESOLUTION is right, proven by the Code()/Handle() edges
* above, and that no ctor edge binds to a variant the caller cannot
* see: neither the BackOffice nor the registry-first decoy. */
ok[g][2] = !has_edge_from_callable_to_qn(
gb, "NestedUser.Run",
"cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand.Result",
"CALLS") &&
!has_edge_from_callable_to_qn(
gb, "NestedUser.Run",
"cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand."
"Result.Result",
"CALLS");
ok[g][3] = !has_edge_from_callable_to_qn(gb, "NestedUser.Run",
"cbm_cs_nested.src.Decoy.Layouts.GrainCommand."
"GrainCommand.Result.Result",
"CALLS");
ok[g][4] = has_edge_from_callable_to_qn(gb, "ChainedUser.Run", pf_handle, "CALLS");
ok[g][5] = !has_edge_from_callable_to_qn(gb, "ChainedUser.Run", decoy_handle, "CALLS");
ok[g][6] = has_edge_from_callable_to_qn(gb, "Boot.Boot1", global_check, "CALLS");
ok[g][7] = !has_edge_from_callable_to_qn(gb, "Boot.Boot1", other_check, "CALLS");
ok[g][8] = has_edge_from_callable_to_qn(gb, "ImportUser.Run2", global_check, "CALLS");
ok[g][9] = !has_edge_from_callable_to_qn(gb, "ImportUser.Run2", other_check, "CALLS");
ok[g][10] = has_edge_from_callable_to_qn(gb, "NsUsingUser.Run3", other_check, "CALLS");
ok[g][11] = !has_edge_from_callable_to_qn(gb, "NsUsingUser.Run3", global_check, "CALLS");
for (int k = 0; k < N_CHECKS; k++) {
/* The ctor-edge checks 2/3 gate the parallel path only. In the
* sequential path the `new GrainCommand.Result(true)` edge is
* still drawn by the textual ctor carrier, not by the resolved
* type (the out-of-scope carrier join above); on current main it
* lands on the BackOffice Result already with the nested-type
* resolution alone. The diagnostic below still prints both. */
if (g == 0 && (k == 2 || k == 3))
continue;
all = all && ok[g][k];
}
}
if (!all) {
for (int g = 0; g < 2; g++) {
printf(" C# nested/global diagnostic %s: nested_code=%d/%d nested_ctor=%d/%d "
"chained=%d/%d global_ns=%d/%d cu_using=%d/%d ns_using=%d/%d\n",
g == 0 ? "sequential" : "parallel", ok[g][0], ok[g][1], ok[g][2], ok[g][3],
ok[g][4], ok[g][5], ok[g][6], ok[g][7], ok[g][8], ok[g][9], ok[g][10],
ok[g][11]);
}
}
cbm_gbuf_free(graphs[0]);
cbm_gbuf_free(graphs[1]);
th_rmtree(tmpdir);
ASSERT_TRUE(all);
PASS();
}
/* A type extracted with no namespace is global only if its file parsed
* cleanly. The vendored grammar does not parse a `ref partial struct` with
* members (reference assemblies are full of them), and error recovery
* dissolves the namespace around it, so the static class after it surfaces
* with no namespace. Read as global, it outranked the compilation-unit
* `using` and took every `Gadget.Spin()` call from the real, visible type;
* on dotnet/runtime that moved 5,313 CALLS from implementation files into
* reference assemblies. The damaged file is registered FIRST so neither the
* old registry order nor the global rank can explain a pass. */
TEST(parallel_csharp_parse_damaged_namespace_is_not_global) {
static const char damaged_src[] = "namespace Acme.Lib\n"
"{\n"
" public ref partial struct Handler\n"
" {\n"
" public void Append(string value) { }\n"
" }\n"
" public static partial class Gadget {\n"
" public static int Spin() => 9;\n"
" }\n"
"}\n";
static const char real_src[] = "namespace Acme.Lib;\n"
"public static class Gadget {\n"
" public static int Spin() => 1;\n"
"}\n";
static const char user_src[] = "using Acme.Lib;\n"
"namespace Acme.App;\n"
"public class GadgetUser {\n"
" public int Run() {\n"
" return Gadget.Spin();\n"
" }\n"
"}\n";
const char *project = "cbm_cs_damaged";
static const char *rels[] = {"src/Ref/Lib.cs", "src/Lib/Gadget.cs", "src/App/GadgetUser.cs"};
const char *srcs[] = {damaged_src, real_src, user_src};
enum { N_FILES = 3 };
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_damaged_XXXXXX");
if (!cbm_mkdtemp(tmpdir)) {
FAIL("mkdtemp failed");
}
char paths[N_FILES][512];
cbm_file_info_t files[N_FILES] = {0};
for (int i = 0; i < N_FILES; i++) {
snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]);
if (th_write_file(paths[i], srcs[i]) != 0) {
th_rmtree(tmpdir);
FAIL("failed to write C# damaged-namespace fixture");
}
files[i].path = paths[i];
files[i].rel_path = (char *)rels[i];
files[i].language = CBM_LANG_CSHARP;
}
cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL};
g_harness_cs_registry = true;
graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2);
g_harness_cs_registry = false;
ASSERT_NOT_NULL(graphs[0]);
ASSERT_NOT_NULL(graphs[1]);
const char *real_spin = "cbm_cs_damaged.src.Lib.Gadget.Gadget.Spin";
const char *damaged_spin = "cbm_cs_damaged.src.Ref.Lib.Gadget.Spin";
bool ok[2][2];
for (int g = 0; g < 2; g++) {
ok[g][0] = has_edge_from_callable_to_qn(graphs[g], "GadgetUser.Run", real_spin, "CALLS");
ok[g][1] = !has_edge_from_callable_to_qn(graphs[g], "GadgetUser.Run", damaged_spin,
"CALLS");
}
bool all = ok[0][0] && ok[0][1] && ok[1][0] && ok[1][1];
if (!all) {
printf(" C# damaged-namespace diagnostic: sequential real=%d no_damaged=%d "
"parallel real=%d no_damaged=%d\n",
ok[0][0], ok[0][1], ok[1][0], ok[1][1]);
}
cbm_gbuf_free(graphs[0]);
cbm_gbuf_free(graphs[1]);
th_rmtree(tmpdir);
ASSERT_TRUE(all);
PASS();
}
/* External Kotlin protocol targets must not borrow a project method merely
* because the final Class.method segments agree. Raw assertions prove that the
* iterator/destructuring carriers and their stdlib semantic rows really exist;
@@ -5694,6 +5999,8 @@ SUITE(parallel) {
RUN_TEST(parallel_typescript_import_namespace_exact_parity);
RUN_TEST(parallel_tsx_import_namespace_exact_parity);
RUN_TEST(parallel_csharp_same_name_class_binds_by_namespace);
RUN_TEST(parallel_csharp_nested_type_and_global_namespace_binding);
RUN_TEST(parallel_csharp_parse_damaged_namespace_is_not_global);
RUN_TEST(parallel_kotlin_external_protocol_does_not_use_project_class_method_tail);
RUN_TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph);
RUN_TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest);
+1359 -69
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -1260,6 +1260,45 @@ TEST(go_bare_ref_never_binds_field) {
PASS();
}
TEST(go_bare_init_never_binds_init_namesake) {
/* Go cannot refer to a package init function, and a method or field
* named init needs a selector, so a bare `init` (a closure, a parameter,
* a `package init` clause) is always a local name. */
/* Every Go Function named init is a package init: refused for every
* registry strategy, same_module included (the pre-#1915 binding). */
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Function"));
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Function"));
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Function"));
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", NULL, "Function"));
/* A Method or Field namesake found by the name alone is refused. */
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Method"));
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "unique_name", "Method"));
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Field"));
/* ... but not one that an import- or module-aware strategy produced. */
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Method"));
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Method"));
/* lsp_* answers are kept: the sequential resolver emits them before its
* registry guards run, so refusing one here would split the resolvers. */
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Function"));
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Method"));
/* The member half of a selector (`w.init()`, `e.init`) is not bare. */
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Method"));
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Field"));
/* Other names and other target kinds are untouched. */
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "initAll", "suffix_match", "Method"));
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "Init", "suffix_match", "Function"));
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Variable"));
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Module"));
/* Go only: elsewhere a bare `init()` is an ordinary call (a Java or C++
* method body calls its own init, a C or Python init is a function). */
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "suffix_match", "Method"));
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "same_module", "Function"));
/* Degenerate input → nothing to judge. */
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, NULL, "suffix_match", "Function"));
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", NULL));
PASS();
}
TEST(c_bare_ref_never_binds_field) {
/* #2666: a C struct member is named only as the member half of `a.b`,
* `a->b` or a designator `.b` (field_identifier → is_member_access). A
@@ -2656,6 +2695,7 @@ SUITE(registry) {
RUN_TEST(registry_tie_prefers_csharp_implementation_over_ref_stub);
RUN_TEST(cross_language_ref_drops_go_vs_c);
RUN_TEST(go_bare_ref_never_binds_field);
RUN_TEST(go_bare_init_never_binds_init_namesake);
RUN_TEST(c_bare_ref_never_binds_field);
RUN_TEST(call_onto_field_follows_language_and_shape);
RUN_TEST(c_bare_call_does_not_weigh_field_namesakes);
+17
View File
@@ -620,6 +620,22 @@ TEST(adr_store_and_retrieve) {
PASS();
}
TEST(adr_restore_from_graph_if_absent_memory_is_noop) {
/* A :memory: store keeps ADRs in the graph DB, so restore-if-absent is a
* well-formed no-op (returns OK) and leaves an existing ADR untouched. */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_store(s, "test", "v1"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_restore_from_graph_if_absent(s), CBM_STORE_OK);
cbm_adr_t adr;
ASSERT_EQ(cbm_store_adr_get(s, "test", &adr), CBM_STORE_OK);
ASSERT_STR_EQ(adr.content, "v1");
cbm_store_adr_free(&adr);
cbm_store_close(s);
PASS();
}
TEST(adr_upsert) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
@@ -2033,6 +2049,7 @@ SUITE(store_arch) {
/* ADR */
RUN_TEST(adr_store_and_retrieve);
RUN_TEST(adr_upsert);
RUN_TEST(adr_restore_from_graph_if_absent_memory_is_noop);
RUN_TEST(adr_delete);
RUN_TEST(adr_delete_not_found);
RUN_TEST(doc_candidates_replace_and_get);