diff --git a/Makefile.cbm b/Makefile.cbm index 12f0fe5c1..4eac7dfce 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -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 diff --git a/README.md b/README.md index 9c41aecad..e7eff2344 100644 --- a/README.md +++ b/README.md @@ -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 (`.adr.db`). | | `index_status` | Check indexing status of a project. | | `check_index_coverage` | Check whether exact paths or a scope are indexed and fresh. A clean result means no recorded gap, not proof of completeness. | diff --git a/graph-ui/package-lock.json b/graph-ui/package-lock.json index 794e775c0..c20a98c36 100644 --- a/graph-ui/package-lock.json +++ b/graph-ui/package-lock.json @@ -32,7 +32,7 @@ "playwright": "1.62.1", "three-stdlib": "2.36.1", "typescript": "5.9.3", - "vite": "6.4.4", + "vite": "6.4.5", "vitest": "4.1.12" } }, @@ -5277,9 +5277,9 @@ } }, "node_modules/vite": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", - "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/graph-ui/package.json b/graph-ui/package.json index 7d3b542b8..0fcf6ab3e 100644 --- a/graph-ui/package.json +++ b/graph-ui/package.json @@ -62,7 +62,7 @@ "playwright": "1.62.1", "three-stdlib": "2.36.1", "typescript": "5.9.3", - "vite": "6.4.4", + "vite": "6.4.5", "vitest": "4.1.12" }, "overrides": { diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 1c4fb7194..1a38c6f51 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -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 */ diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 155f08dfa..979af928c 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -718,6 +718,66 @@ static bool elixir_call_head_in(TSNode call, const char *source, const char *con call_node_text_in(ts_node_child(call, 0), source, heads); } +/* A definition's head declares a name; it is never a call to it. The head under + * a guard is not the def's first argument -- `def f(x) when g` parses its whole + * head as a `when` binary_operator -- so the plain node comparison below stopped + * recognising it, and the inner `f(x)` was recorded as a call to `f`. Under the + * widened span that phantom lands inside the function's own node, and cbm.c + * decides self-recursion by line containment, so an ordinary two-clause guarded + * function reported itself recursive. `recursive` is a queryable node property + * and seeds the cycle detection in pass_complexity, so that is a load-bearing + * signal, not a cosmetic one. The suppression therefore travels with the fold + * rather than following it. + * + * The head comes from cbm_elixir_def_head_is (helpers.c) rather than a private + * peel here, because three files read this same node and all three have to + * agree on which node is the head, or a node one of them is treating as a + * definition name is recorded by another as a reference to that very name: + * - extract_defs.c, extract_elixir_func_def, unwraps it to NAME the + * definition. It and this file disagreeing mints the phantom above. + * - extract_usages.c, is_elixir_def_binding, treats an identifier inside that + * head as a binding occurrence rather than a reference, which is what keeps + * a head suppressed here from re-emerging as a USAGE edge: a suppression in + * one extractor of the unified walk only REMOVES a phantom if the others + * also decline the node. The two files ask different questions of the same + * helper -- this one whether the node IS the head or a `when` wrapper above + * it, that one whether the node sits INSIDE the head -- so there is one + * definition of the head and they cannot drift apart. That containment + * stops at the head rather than covering the whole `when` operator, which + * is deliberate in the other direction: only the parameters being BOUND are + * excluded, and a parameter READ in the guard is a reference, exactly as + * the same read in the body is. + * - extract_unified.c, compute_elixir_func_qn, resolves a def's QN to open + * the function's call scope and reads the head through the same helper. + * Handed the `when` operator instead it returns NULL, no scope is pushed, + * and every call in a guarded body is sourced from the File rather than + * from the enclosing Function. That failure is independent of the two above + * because it decides edge SOURCE, not edge existence. + * + * Suppressing the head does not only delete edges; it moves metadata on edges + * that SURVIVE, and that is worth stating because a caller reading an edge's + * `line` will see a different number than before. `edges` is UNIQUE on + * (source, target, type), so where the def-head phantom happened to be the + * dedup survivor for a pair, deleting it promotes some other row for that same + * pair and the surviving edge takes that row's `line`. + * + * Measured by indexing one 970-file Elixir lib tree with the binary built from + * this commit's parent and with this one, and diffing the `edges` rows out of + * the two SQLite stores keyed on (source QN, target QN, type): 2,436 edges go + * and NONE is added -- 2,104 CALLS and 332 USAGE. Of the 119,696 edges that + * survive, 1,054 record a different `line`. + * + * The typespec attributes -- `@spec f(t) :: u` and the @callback / @type family + * -- put the declared name in a `call` node too, and are deliberately NOT + * suppressed here. A def head can be, because extract_usages.c already treats a + * def's first argument as a binding. Nothing there treats a typespec subject as + * one, so declining it in this walk would not remove its phantom, it would + * RELABEL it: handle_usages reaches the bare identifier handle_calls just + * declined and mints a USAGE onto the same function, which under + * UNIQUE(source, target, type) is a separate row that no longer collides with + * the real call it used to hide behind, and which pass_importance then counts. + * A typespec phantom has to be removed before any extractor sees it, by + * skipping the whole subtree in the unified walk. */ static bool elixir_call_is_definition_role(TSNode node, const char *source) { static const char *const structural_heads[] = {"def", "defp", "defmacro", "defmodule", NULL}; static const char *const function_heads[] = {"def", "defp", "defmacro", NULL}; @@ -735,7 +795,7 @@ static bool elixir_call_is_definition_role(TSNode node, const char *source) { TSNode signature = ts_node_named_child_count(arguments) > 0 ? ts_node_named_child(arguments, 0) : arguments; - return ts_node_eq(signature, node); + return cbm_elixir_def_head_is(signature, node); } return false; } @@ -757,7 +817,23 @@ static bool call_node_is_definition_container(CBMLanguage lang, TSNode node, con if (lang == CBM_LANG_AGDA && strcmp(kind, "expr") == 0) { return agda_expr_is_definition_role(node); } - return lang == CBM_LANG_ELIXIR && strcmp(kind, "call") == 0 && + /* A guarded head is a `when` binary_operator, not a `call`, and Elixir's + * call node types include binary_operator -- so the head reaches this walk + * and must be able to answer that it is a definition. A paren-less clause + * (`def f when g`) has no inner call at all, so the operator node itself + * reaches extract_callee_name and, with no callee of its own, takes that + * function's last resort -- the first identifier child -- minting a phantom + * CALLS edge onto the very function being defined. + * + * That last resort overrides a deliberate NULL for every Elixir + * binary_operator extract_scripting_callee declines (`=`, `<-`, `->`, `\\`, + * `::`, `when`), so `def g(c \\ 2)` still emits a phantom `c` and + * `e = target(d)` a phantom `e`. That is the pre-existing behaviour for + * those operators and is untouched here: only a `when` operator is + * admitted, so an operator definition's own head (`def a + b`) stays an + * ordinary node, as it was before guards were handled at all. */ + return lang == CBM_LANG_ELIXIR && + (strcmp(kind, "call") == 0 || cbm_elixir_is_when_guard(node)) && elixir_call_is_definition_role(node, source); } diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 51cf7279f..46b1b51dc 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -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); diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index acada15ad..857a43ea1 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -603,6 +603,11 @@ static const char *compute_elixir_func_qn(CBMExtractCtx *ctx, TSNode node) { if (ts_node_is_null(first_arg)) { return NULL; } + /* A guard wraps the whole head in a `when` operator, so the head naming the + * function is its left operand. Left wrapped, this returns NULL and the def + * opens NO function scope: every call in a guarded body then sources to the + * FILE node and the function itself reports no outgoing edges. */ + first_arg = cbm_elixir_def_head_unwrap_guard(first_arg); const char *fk = ts_node_type(first_arg); char *name = NULL; if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) { @@ -2154,6 +2159,111 @@ static bool is_unified_trivia_node(TSNode node) { return ts_node_is_extra(node); } +static bool unified_node_text_equals(const CBMExtractCtx *ctx, TSNode node, const char *expected) { + if (ts_node_is_null(node) || !ctx->source || !expected) { + return false; + } + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + size_t len = strlen(expected); + return end >= start && (size_t)(end - start) == len && + memcmp(ctx->source + start, expected, len) == 0; +} + +/* An Elixir module attribute parses as `unary_operator(@, call(, args))`, + * so the typespec family reaches the unified walk in the shape real code has: + * `@spec foo(t) :: t` puts `foo(t)` in the same `call` node an invocation would + * occupy, and `@type entry :: String.t()` does the same to a type name. The + * walk then reads a declaration as code and mints reference edges from it. + * + * Measured on a fixture of three typespec lines (`@type state :: map()`, `@spec + * fetch(opts) :: state`, `@spec load(User.t()) :: :ok`) beside `def state`, + * `def opts` and a remote `MyApp.User.t/0`: seven edges, every one phantom and + * every one landing on a Function. CALLS onto each specified function; + * CALLS + USAGE + WRITES onto `state`, the WRITES asserting a mutation that + * does not exist; and USAGE onto `opts` and onto `MyApp.User.t/0`, because a + * bare or remote TYPE name resolves to the same-named FUNCTION. On a codebase + * whose convention is @spec on every public function, fan_in therefore never + * reaches 0 and "which exported functions nothing calls" is unanswerable. + * + * The whole subtree is skipped, not just the specified name. What that costs is + * not uniform across the six heads, and both halves were measured by indexing + * one fixture repo -- a declaring file per head plus the file each one reaches + * -- with the pristine binary and with this one: + * + * spec, type, typep, opaque. A type reference resolves onto a FUNCTION or + * onto nothing, never onto the type it names, because a type declaration + * mints no node. `@spec build(MyApp.User.t())` where that module's `t` is a + * `@type` produced no edge in either build, and `@type ext :: Ecto.Schema.t()` + * naming a module outside the repo produced none either. The references that + * do resolve land on a same-named Function, cross-file included -- `USAGE + * lib/decl_type.ex -> Function type_target @lib/remote_type.ex`, and the + * @typep and @opaque equivalents. A Function standing in for a Type is the + * pollution rather than a record of it, so nothing correct is lost here. + * + * callback, macrocallback. The declared name IS a function name, so it + * resolves onto an implementation elsewhere in the corpus: pristine yields + * `CALLS lib/decl_callback.ex -> Function callback_fun @lib/impl_callback.ex` + * and the @macrocallback equivalent, both cross-file, and this build yields + * neither. That edge does point at a real Behaviour-to-Impl relationship, and + * nothing replaces it -- `@behaviour MyBehaviour` mints no edge of its own, + * so after this change nothing in the graph links a behaviour to its + * implementations. It is deleted anyway because it is name resolution and not + * a behaviour model: on a fixture with one @callback and three same-named + * `handle_it/1` defs, the single edge landed on the one module that + * implements nothing, leaving both real implementors at fan_in 0. Recording + * that relationship correctly means minting it from `@behaviour`, which is a + * separate change and not a reason to keep an arbitrary edge. + * + * The other accepted cost: `unquote(...)` inside a typespec does execute at + * compile time, so `@type t :: unquote(build_type())` loses its genuine + * `build_type` call edge along with the phantoms. + * + * Only this family is skipped. Every other attribute VALUE is ordinary + * compile-time code -- `@timeout Application.compile_env(:app, :timeout)` + * really does call compile_env -- so those subtrees are walked exactly as + * before, and their attribute NAME is still emitted as a callee: `@behaviour + * GenServer`, `@timeout 5_000` and `@doc "x"` each still mint one phantom CALLS + * onto a same-named Function. That half of the defect class is untouched here + * and needs its own fix. + * + * This subtree skip is one of two mechanisms, and neither subsumes the other. + * It removes the phantom a typespec LINE mints; the def-head phantom is removed + * separately, by the calls walk recognising a `when`-wrapped head as a + * declaration (elixir_call_is_definition_role, extract_calls.c). A function + * carrying both an @spec and a `when` clause needs both: with only this one it + * keeps the def-head reference, with only the other it keeps the @spec one. + * `extract_elixir_typespec_attribute_is_not_code` pins that by asserting 0 + * references onto such a function, so removing either mechanism fails it. + * + * Measured over forge-symphony-graph/lib (970 .ex files) by classifying the + * source line every CALLS/USAGE/WRITES edge records: a build without this skip + * anchors 4,565 CALLS edges on a typespec-attribute line, and this build + * anchors 0 -- with no USAGE or WRITES appearing in their place, which is what + * a relabel rather than a removal would look like. Total USAGE edges fall from + * 25,393 to 23,044 over the same corpus; they do not rise. */ +static bool is_elixir_typespec_attribute(const CBMExtractCtx *ctx, TSNode node) { + static const char *const typespec_heads[] = { + "spec", "callback", "macrocallback", "type", "typep", "opaque", NULL}; + if (ctx->language != CBM_LANG_ELIXIR || strcmp(ts_node_type(node), "unary_operator") != 0 || + !unified_node_text_equals(ctx, ts_node_child_by_field_name(node, TS_FIELD("operator")), + "@")) { + return false; + } + TSNode operand = ts_node_child_by_field_name(node, TS_FIELD("operand")); + if (ts_node_is_null(operand) || strcmp(ts_node_type(operand), "call") != 0 || + ts_node_child_count(operand) == 0) { + return false; + } + TSNode head = ts_node_child(operand, 0); + for (const char *const *name = typespec_heads; *name; name++) { + if (unified_node_text_equals(ctx, head, *name)) { + return true; + } + } + return false; +} + // JS/TS `export_statement` appears in import_node_types so re-exports // (`export { X } from './m'`) are treated as an import boundary. But it also // wraps exported *declarations* (`export function f(cfg: Config) {}`), and @@ -2755,7 +2865,8 @@ void cbm_extract_unified(CBMExtractCtx *ctx) { break; } bool trivia = is_unified_trivia_node(node); - if (!trivia) { + bool typespec = is_elixir_typespec_attribute(ctx, node); + if (!trivia && !typespec) { /* Trivia consumes no semantic state. Scope expiry may be deferred * until the next code-bearing node; pop restores the displaced * tuple and push applies the new frame's effect, both O(1) -- @@ -2788,7 +2899,7 @@ void cbm_extract_unified(CBMExtractCtx *ctx) { * asking the cursor to construct a child iterator for every one of * hundreds of thousands of flat comment siblings. Structured extras * still descend normally. */ - if ((!trivia || ts_node_child_count(node) > 0) && + if (!typespec && (!trivia || ts_node_child_count(node) > 0) && ts_tree_cursor_goto_first_child(&cursor)) { depth++; continue; diff --git a/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index a009b7964..bd49b759b 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -617,6 +617,11 @@ static bool is_elixir_def_binding(CBMExtractCtx *ctx, TSNode node) { TSNode signature = ts_node_named_child_count(arguments) > 0 ? ts_node_named_child(arguments, 0) : arguments; + /* A guard makes that signature the whole `when` operator, which spans the + * guard expression as well as the head. Unwrapped to the head, only the + * parameters being BOUND are excluded here; a parameter READ inside the + * guard stays a usage, as the same read in the body already is. */ + signature = cbm_elixir_def_head_unwrap_guard(signature); return node_contains(head, node) || node_contains(signature, node); } return false; diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index d6a8e73b9..796a9a4ef 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1382,6 +1382,93 @@ const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, c return scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name; } +/* ── Elixir def-head guards ─────────────────────────────────── */ + +/* The guard sits on the head, not beside it: `def name(args) when guard` parses + * as a single `when` binary_operator whose left operand is `name(args)`. The + * operator field is an exact anonymous token, so the check is the token type + * rather than the node kind — `def a + b` is a binary_operator head too, and + * unwrapping it would name the function after its left parameter. */ +bool cbm_elixir_is_when_guard(TSNode node) { + if (ts_node_is_null(node) || strcmp(ts_node_type(node), "binary_operator") != 0) { + return false; + } + TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator")); + return !ts_node_is_null(op) && strcmp(ts_node_type(op), "when") == 0; +} + +/* One `when` level off a def's first argument, or the node unchanged. + * + * There is deliberately no named-child fallback for a missing `left`. An infix + * operator cannot reduce without a left operand, so tree-sitter-elixir never + * builds a `when` binary_operator that lacks one: measured over every guarded + * shape in the suite plus a corpus of truncated heads (`def when true`, + * `def f(x) when`, `def f(x) when when is_x(x)`, `def when() when when`), + * `left` was present on all 66 unwraps, ERROR-recovered parses included, and + * every one had the left operand as named child 0. Where the left operand was + * genuinely absent the grammar produced no `when` operator at all -- it read + * `when` as the head identifier -- so the unwrap is never entered. A + * named-child fallback would therefore be dead on a well-formed tree and, on + * the hypothetical malformed one, would hand back named child 0 without + * knowing it is the left operand rather than the guard expression: naming the + * function after its own guard. Returning the node unchanged degrades to the + * pre-guard behaviour instead, which is the safe direction. */ +static TSNode elixir_unwrap_one_guard(TSNode first_arg) { + if (!cbm_elixir_is_when_guard(first_arg)) { + return first_arg; + } + TSNode lhs = ts_node_child_by_field_name(first_arg, TS_FIELD("left")); + return ts_node_is_null(lhs) ? first_arg : lhs; +} + +/* The head under every guard a def's first argument carries, or first_arg + * unchanged when it carries none. Elixir admits more than one guard on a + * clause -- `def f(x) when is_atom(x) when is_binary(x)` -- and + * tree-sitter-elixir nests those RIGHT-associatively, measured on that exact + * source: `when(f(x), when(is_atom(x), is_binary(x)))`. The declared head is + * therefore the outer operator's left operand and one peel reaches it. The + * peel still runs to a fixed point: the extra iteration is one token compare, + * and it is what makes the helper's contract "the head under EVERY guard" + * rather than "the head under the first one", which is the property the three + * walks rely on. + * + * Left wrapped, the defs walk drops the clause, the unified walk opens no + * function scope (so every call in a guarded body sources to the FILE node), + * the calls walk stops recognising the head and emits it as an invocation of + * the very function being defined, and the usages walk swallows every + * identifier in the guard as part of the binding. Four walks read this same + * argument, which is why the unwrap lives here rather than in any one of + * them. */ +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg) { + for (;;) { + TSNode next = elixir_unwrap_one_guard(first_arg); + if (ts_node_eq(next, first_arg)) { + return first_arg; + } + first_arg = next; + } +} + +/* True when `node` is the head a def declares, or any of the `when` guard + * operators wrapping it. The calls walk needs the whole chain, not just its + * ends: each member reaches that walk by its own route -- `def f(x) when g` + * arrives as the inner `f(x)` call, `def f when g` has no inner call at all and + * the operator itself falls through to extract_callee_name's first-identifier + * last resort -- and a member it fails to recognise as a declaration is emitted + * as an invocation of the function being defined. */ +bool cbm_elixir_def_head_is(TSNode signature, TSNode node) { + for (;;) { + if (ts_node_eq(signature, node)) { + return true; + } + TSNode next = elixir_unwrap_one_guard(signature); + if (ts_node_eq(next, signature)) { + return false; + } + signature = next; + } +} + static const char *func_node_name(CBMArena *a, TSNode func_node, const char *source, CBMLanguage lang) { // Wolfram: set_delayed_top/set_top/set_delayed/set — LHS is apply(user_symbol("f"), ...) diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 4c96cb5f0..2e6b669fe 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -157,6 +157,26 @@ const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char // attrpath and an enclosing attrset compose into one qualified name. const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name); +// ── Elixir def-head guards ── +// `def f(x) when g` parses its WHOLE head as a `when` binary_operator, so the +// `f(x)` call naming the function is the operator's left operand, and more than +// one guard (`when a when b`) nests those operators right-associatively, so the +// head stays the outermost operator's left operand in either shape. Four +// walks read that same first argument — the defs walk names the function from +// it, the unified walk opens the function's call scope from it, the calls walk +// tells a head apart from an invocation by it, and the usages walk excludes the +// head's identifiers from the usage set by it — so a private copy in one of +// them makes the rest disagree about what a guarded clause is. +// cbm_elixir_is_when_guard answers "is this node a guarded head"; the unwrap +// returns the head under every guard, or the node unchanged when it carries +// none; cbm_elixir_def_head_is answers whether a node is that head or one of +// the guard operators above it, which is the question the calls walk asks. +// All three test the operator token, so an operator definition (`def a + b`) is +// left alone rather than mis-read as a guard. +bool cbm_elixir_is_when_guard(TSNode node); +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg); +bool cbm_elixir_def_head_is(TSNode signature, TSNode node); + // Resolve a function/method definition node's NAME node across all ~130 grammars // (generic `name` field, arrow→declarator, C/C++ declarator chain, plus the many // per-language quirks: Fortran subroutine, SCSS mixin, SQL create_function, R, diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index 023a963af..266b846a3 100644 --- a/internal/cbm/lsp/cs_lsp.c +++ b/internal/cbm/lsp/cs_lsp.c @@ -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; } diff --git a/internal/cbm/lsp/cs_lsp.h b/internal/cbm/lsp/cs_lsp.h index 27cc233a5..4c47fc67d 100644 --- a/internal/cbm/lsp/cs_lsp.h +++ b/internal/cbm/lsp/cs_lsp.h @@ -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; diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 503d18538..ee9c6d5f9 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -46,6 +46,25 @@ static const lib_pattern_t http_libraries[] = { * case-sensitive, so without this entry `Axios.get(url)` fell through to * the `.get` route-suffix fallback and became a route registration. */ {"Axios", CBM_SVC_HTTP, NULL}, + /* Angular HttpClient (#2768). The receiver rows end in a dot, like "gin.": + * a bare "this.http" / "$http" would also match this.httpAdapter.get + * (NestJS route registration) and $httpBackend.expectGET (an AngularJS test + * double), because an uppercase letter after an id counts as CamelCase + * glue. A chain wrapped before the verb keeps its line break in the callee + * text ("this.http\n .post", prettier's style), so each receiver also + * has a line-break twin: LF, and CR for CRLF files. Java/C# HttpClient is + * already listed below. */ + {"@angular/common/http", CBM_SVC_HTTP, NULL}, + {"angular.common.http", CBM_SVC_HTTP, NULL}, + {"this.http.", CBM_SVC_HTTP, NULL}, + {"this.http\n", CBM_SVC_HTTP, NULL}, + {"this.http\r", CBM_SVC_HTTP, NULL}, + {"httpClient.", CBM_SVC_HTTP, NULL}, + {"httpClient\n", CBM_SVC_HTTP, NULL}, + {"httpClient\r", CBM_SVC_HTTP, NULL}, + {"$http.", CBM_SVC_HTTP, NULL}, + {"$http\n", CBM_SVC_HTTP, NULL}, + {"$http\r", CBM_SVC_HTTP, NULL}, {"superagent", CBM_SVC_HTTP, NULL}, {"needle", CBM_SVC_HTTP, NULL}, {"node-fetch", CBM_SVC_HTTP, NULL}, diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index 6eeabfa44..d9f632e7c 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -74,9 +74,9 @@ src/pipeline/pass_semantic.c 3 src/pipeline/pass_tests.c 3 src/pipeline/pass_usages.c 4 src/pipeline/path_alias.c 40 -src/pipeline/pipeline.c 132 +src/pipeline/pipeline.c 129 src/pipeline/pipeline_delta.c 19 -src/pipeline/pipeline_incremental.c 140 +src/pipeline/pipeline_incremental.c 130 src/pipeline/registry.c 17 src/pipeline/worker_pool.c 2 src/store/store.c 354 diff --git a/src/cli/cli.c b/src/cli/cli.c index f29f3cc64..5865e6adb 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -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 (".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 ".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++; } } diff --git a/src/cli/config_text_edit.c b/src/cli/config_text_edit.c index ad728f506..38bea7b4d 100644 --- a/src/cli/config_text_edit.c +++ b/src/cli/config_text_edit.c @@ -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, ®ion) != 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; diff --git a/src/cli/config_text_edit.h b/src/cli/config_text_edit.h index 3f6e7c425..f2befb408 100644 --- a/src/cli/config_text_edit.h +++ b/src/cli/config_text_edit.h @@ -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); diff --git a/src/daemon/application.c b/src/daemon/application.c index eb9bf97f4..a5216846b 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -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); diff --git a/src/foundation/str_util.c b/src/foundation/str_util.c index 00c6b4e68..76b30deb2 100644 --- a/src/foundation/str_util.c +++ b/src/foundation/str_util.c @@ -317,7 +317,9 @@ bool cbm_is_internal_cache_db(const char *filename) { bool cbm_is_project_index_db(const char *filename) { static const char db_ext[] = ".db"; + static const char adr_ext[] = ".adr.db"; const size_t ext_len = sizeof(db_ext) - 1; + const size_t adr_len = sizeof(adr_ext) - 1; if (!filename) { return false; } @@ -325,6 +327,12 @@ bool cbm_is_project_index_db(const char *filename) { if (len <= ext_len || strcmp(filename + len - ext_len, db_ext) != 0) { return false; } + /* A per-project ADR sidecar (".adr.db") ends in ".db" but is not itself + * an index — exclude it from every index scan/count (MCP loops + the CLI's + * cbm_list_indexes / count_db_indexes all gate on this helper). */ + if (len >= adr_len && strcmp(filename + len - adr_len, adr_ext) == 0) { + return false; + } return !cbm_is_internal_cache_db(filename); } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 74d318039..de03ec06c 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -769,7 +769,7 @@ static const tool_def_t TOOLS[] = { "\"offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"metadata_only\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Compatibility: omit counts, size, and branch.\"}}}"}, - {"delete_project", "Delete a project from the index", + {"delete_project", "Delete a project from the index, including its ADR store", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" "\"project\"]}"}, @@ -7885,7 +7885,9 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { return result; } -/* delete_project: just erase the .db file (and WAL/SHM). */ +/* delete_project: erase the .db file (and WAL/SHM) plus the ADR sidecar + * ".adr.db" (and its WAL/SHM/journal), so deleting a project removes its + * ADR store with it. */ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { char *name = get_project_arg(args); if (!name) { @@ -7924,18 +7926,37 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { const char *error_detail = NULL; bool is_error = false; + /* ADR sidecar lives beside the graph DB as ".adr.db" — remove it (and + * its own WAL/SHM/journal) whenever the project is deleted. Sized as the + * graph path plus the ".adr.db" suffix. */ + char adr_path[CBM_SZ_1K + sizeof(".adr.db")]; + int adr_n = snprintf(adr_path, sizeof(adr_path), "%s.adr.db", path); + bool have_adr_path = adr_n > 0 && (size_t)adr_n < sizeof(adr_path); + if (exists) { int rc = cbm_unlink(path); (void)cbm_unlink(wal); (void)cbm_unlink(shm); if (rc == 0) { status = "deleted"; + /* Only drop the ADR store once the graph DB is actually gone, so a + * failed graph delete leaves the project (and its ADR) intact. */ + if (have_adr_path) { + (void)cbm_unlink(adr_path); + (void)cbm_remove_db_sidecars(adr_path); + } } else { status = "delete_failed"; error_detail = strerror(errno); is_error = true; } } else { + /* No graph DB, but a sidecar could still linger (e.g. ADR written then + * the index deleted out of band) — best-effort clean it up. */ + if (have_adr_path) { + (void)cbm_unlink(adr_path); + (void)cbm_remove_db_sidecars(adr_path); + } is_error = true; } @@ -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 (".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(); diff --git a/src/pipeline/artifact.c b/src/pipeline/artifact.c index 0d6ef3010..f3b6098ef 100644 --- a/src/pipeline/artifact.c +++ b/src/pipeline/artifact.c @@ -884,13 +884,54 @@ static void artifact_snapshot_tmp_close(artifact_snapshot_tmp_t *tmp) { tmp->dir[0] = '\0'; } +/* Make the snapshot's project_summaries carry exactly the current ADR — and + * nothing stale. ADRs now live in the ".adr.db" sidecar, but a legacy + * pre-sidecar row can still linger in the graph DB and gets copied into the + * snapshot by VACUUM INTO; incremental/closure-repair clones keep carrying it. + * So ALWAYS clear project_summaries first, then insert only when an ADR is + * present (`adr` is NULL when the sidecar has none). Otherwise a no-ADR export + * would ship the stale row and a teammate's import would restore a decision + * record that was deleted. Best-effort: on any failure the artifact simply + * omits the ADR, as before the sidecar existed. */ +static void snapshot_inject_adr(const char *snapshot_path, const cbm_adr_t *adr) { + sqlite3 *db = NULL; + if (sqlite3_open_v2(snapshot_path, &db, SQLITE_OPEN_READWRITE, NULL) != SQLITE_OK) { + sqlite3_close(db); + return; + } + sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS project_summaries (" + " project TEXT PRIMARY KEY, summary TEXT NOT NULL, source_hash TEXT NOT NULL," + " created_at TEXT NOT NULL, updated_at TEXT NOT NULL);", + NULL, NULL, NULL); + /* Drop any stale row (a deleted ADR must not resurrect through the export). */ + sqlite3_exec(db, "DELETE FROM project_summaries;", NULL, NULL, NULL); + if (adr && adr->project && adr->content) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "INSERT INTO project_summaries " + "(project, summary, source_hash, created_at, updated_at) " + "VALUES (?1, ?2, '', ?3, ?4);", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, adr->project, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, adr->content, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, adr->created_at ? adr->created_at : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, adr->updated_at ? adr->updated_at : "", -1, SQLITE_TRANSIENT); + sqlite3_step(st); + sqlite3_finalize(st); + } + } + sqlite3_close(db); +} + /* Prepare a stripped DB copy for best-quality export. - * VACUUM INTO → (optionally) drop indexes → VACUUM. Returns malloc'd buffer - * or NULL. VACUUM INTO runs on BOTH quality levels: it is the consistent - * snapshot — the store runs in WAL mode, so raw main-file bytes miss - * committed transactions still in the -wal and can be mid-checkpoint torn - * (#895). Only the index-stripping is BEST-only. */ -static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes) { + * VACUUM INTO → (optionally) drop indexes → VACUUM → (optionally) inject the + * ADR. Returns malloc'd buffer or NULL. VACUUM INTO runs on BOTH quality + * levels: it is the consistent snapshot — the store runs in WAL mode, so raw + * main-file bytes miss committed transactions still in the -wal and can be + * mid-checkpoint torn (#895). Only the index-stripping is BEST-only. */ +static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes, + const cbm_adr_t *adr) { artifact_snapshot_tmp_t tmp; if (!artifact_snapshot_tmp_open(&tmp)) { artifact_export_fail("prepare_snapshot_dir", cbm_tmpdir(), "private_tmpdir_failed", errno); @@ -935,6 +976,11 @@ static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool str } } + /* Carry the ADR (from the sidecar) into the snapshot so the exported + * artifact ships the team's decision record. After the strip/VACUUM, so the + * row is not dropped. */ + snapshot_inject_adr(tmp_path, adr); + /* Reopened by path rather than held on a descriptor across VACUUM INTO, * because sqlite owns the create. The private directory is what makes that * safe: an attacker who cannot enter it cannot swap the file underneath. */ @@ -980,15 +1026,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * char *db_data = NULL; int compression_level = ART_ZSTD_FAST; + /* Fetch the ADR from the sidecar (read-only; does not create it) so the + * snapshot can carry it — the team-shared artifact used to include the ADR + * via project_summaries, and the sidecar must not silently break that. */ + cbm_adr_t adr = {0}; + bool have_adr = false; + cbm_store_t *adr_src = cbm_store_open_path_query(db_path); + if (adr_src) { + have_adr = cbm_store_adr_get(adr_src, project_name, &adr) == CBM_STORE_OK; + cbm_store_close(adr_src); + } + const cbm_adr_t *adr_arg = have_adr ? &adr : NULL; + if (quality == CBM_ARTIFACT_BEST) { compression_level = ART_ZSTD_BEST; - db_data = prepare_snapshot_db(db_path, &db_size, true); + db_data = prepare_snapshot_db(db_path, &db_size, true, adr_arg); } else { /* FAST keeps zstd-3 and its indexes, but still snapshots via * VACUUM INTO: the raw main-file bytes of a live WAL store are a * torn copy (#895). */ - db_data = prepare_snapshot_db(db_path, &db_size, false); + db_data = prepare_snapshot_db(db_path, &db_size, false, adr_arg); } + cbm_store_adr_free(&adr); if (!db_data || db_size == 0) { free(db_data); @@ -1071,6 +1130,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * /* ── Import ──────────────────────────────────────────────────────── */ +/* Restore the imported ADR (carried in the graph DB's project_summaries) into + * the per-project sidecar so it arrives with the shared artifact — even for a + * teammate who already indexed the project (a marked sidecar with no ADR), + * which the marker-gated migrate-once would skip. INSERT OR IGNORE means a + * local ADR is never overwritten. Best-effort: the graph DB is already + * installed when this runs, so a failed restore never fails the import, but it + * is logged with the store's reason so a team ADR that did not arrive is + * diagnosable instead of silently missing. */ +static void artifact_import_restore_adr(const char *cache_db_path) { + cbm_store_t *restore = cbm_store_open_path_existing(cache_db_path); + if (!restore) { + cbm_log_warn("artifact.import", "err", "adr_restore", "detail", "reopen_imported_db", + "path", cache_db_path); + return; + } + if (cbm_store_adr_restore_from_graph_if_absent(restore) != CBM_STORE_OK) { + cbm_log_warn("artifact.import", "err", "adr_restore", "detail", cbm_store_error(restore), + "path", cache_db_path); + } + cbm_store_close(restore); +} + int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { if (!repo_path || !cache_db_path) { return CBM_NOT_FOUND; @@ -1198,6 +1279,8 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { cbm_unlink(wal); cbm_unlink(shm); + artifact_import_restore_adr(cache_db_path); + cbm_log_info("artifact.import", "db", cache_db_path, "size_mb", itoa_buf((int)((size_t)dlen / ART_BYTES_PER_MB))); diff --git a/src/pipeline/lsp_surface.c b/src/pipeline/lsp_surface.c index f8d43ff69..62c35f324 100644 --- a/src/pipeline/lsp_surface.c +++ b/src/pipeline/lsp_surface.c @@ -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; diff --git a/src/pipeline/lsp_surface.h b/src/pipeline/lsp_surface.h index dc198bb65..1a73b3427 100644 --- a/src/pipeline/lsp_surface.h +++ b/src/pipeline/lsp_surface.h @@ -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 */ diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 009a01da3..a673e7539 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -1361,6 +1361,14 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { return 0; } + /* A bare Go `init(...)` calls a local closure or parameter: never a + * package init (unreferable in Go) and never a same-named method found + * by name alone — see cbm_go_suppress_bare_init_bind. MUST match + * pass_parallel.c exactly. */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name, + res.strategy, target_node->label)) { + return 0; + } evidence->candidate_qn = target_node->qualified_name; evidence->calls_emitted |= emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 1f57d1c25..830cacfe1 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -720,6 +720,16 @@ static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *s char method[CBM_SZ_32] = {0}; json_str_prop(props, "url_path", url_path, sizeof(url_path)); json_str_prop(props, "method", method, sizeof(method)); + if (!method[0]) { + char callee[CBM_SZ_256] = {0}; + json_str_prop(props, "callee", callee, sizeof(callee)); + if (callee[0]) { + const char *m = cbm_service_pattern_http_method(callee); + if (m) { + snprintf(method, sizeof(method), "%s", m); + } + } + } if (!url_path[0]) { continue; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 50980cbdc..e02d68c67 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2621,19 +2621,37 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, if (!cbm_service_pattern_is_http_route_literal(norm, call->callee_name)) { continue; } + const char *method = cbm_service_pattern_http_method(call->callee_name); + const char *prefix = method ? method : "ANY"; char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; - snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, cbm_route_canon_path(norm, cpath, sizeof(cpath))); - int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, - "{\"source\":\"arg_url\"}"); + char route_props[CBM_SZ_256]; + if (method) { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\",\"method\":\"%s\"}", + method); + } else { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\"}"); + } + int64_t route_id = + cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, route_props); char esc_c[CBM_SZ_256]; char esc_n[CBM_SZ_256]; cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name); cbm_json_escape(esc_n, sizeof(esc_n), norm); - char eprops[CBM_SZ_512]; - snprintf(eprops, sizeof(eprops), - "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + /* Two escaped 255-byte strings plus the fixed keys and the verb: 1K + * never truncates the JSON (512 could, once the method was added). */ + char eprops[CBM_SZ_1K]; + if (method) { + snprintf( + eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\",\"method\":\"%s\"}", + esc_c, esc_n, method); + } else { + snprintf(eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + } cbm_gbuf_insert_edge(gbuf, source->id, route_id, "HTTP_CALLS", eprops); break; } @@ -2985,6 +3003,29 @@ static const cbm_gbuf_node_t *find_source_node(const cbm_gbuf_t *gbuf, const cha /* Field type hint resolution for obj.Method() with multiple candidates. * Strips C# field prefixes (_ / m_), capitalizes to get type name, and * checks if TypeName.Method or ITypeName.Method exists among candidates. */ +/* #1927: the hint may only bind a candidate whose OWNING dot-segment — the + * segment immediately before the method name — EQUALS the hinted type. The + * previous raw strstr() accepted the hinted name anywhere in the QN, so a + * single-letter receiver (`f.Close()` → hint "F") matched nearly every + * candidate ("F" ⊂ "…FileStore.Close", the method name included) and rebound + * a call whose resolution had already failed to an arbitrary project symbol + * at PP_FIELD_HINT_CONF. Segment equality keeps the intended + * variable-named-after-its-type case (`repo.Find()` → `Repo.Find`, + * lrp_go_s8_field_type_hint) and picks the candidate whose owner actually + * carries the name instead of whichever QN contains the letters. */ +static bool fth_owner_segment_is(const char *candidate_qn, const char *type_name) { + const char *last_dot = strrchr(candidate_qn, '.'); + if (!last_dot || last_dot == candidate_qn) { + return false; + } + const char *seg_start = last_dot; + while (seg_start > candidate_qn && seg_start[-1] != '.') { + seg_start--; + } + size_t seg_len = (size_t)(last_dot - seg_start); + return seg_len > 0 && strncmp(seg_start, type_name, seg_len) == 0 && type_name[seg_len] == '\0'; +} + static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const char *callee_name, int64_t source_id) { if (!res->qualified_name || res->candidate_count <= SKIP_ONE) { @@ -3024,7 +3065,8 @@ static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const int cand_count = 0; cbm_registry_find_by_name(rc->registry, method, &cands, &cand_count); for (int ci = 0; ci < cand_count; ci++) { - if (strstr(cands[ci], type_name) || strstr(cands[ci], iface_name)) { + if (fth_owner_segment_is(cands[ci], type_name) || + fth_owner_segment_is(cands[ci], iface_name)) { const cbm_gbuf_node_t *better = cbm_gbuf_find_by_qn(rc->main_gbuf, cands[ci]); if (better && better->id != source_id) { res->qualified_name = cands[ci]; @@ -3659,6 +3701,13 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * Variable or a Field. */ continue; } + if (target_node && source_node->id != target_node->id && + cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name, + res.strategy, target_node->label)) { + /* Same guard as pass_calls.c — a bare Go `init` is a local name, + * never a package init or a method found by name alone. */ + continue; + } if (!target_node || source_node->id == target_node->id) { /* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`) * resolve to an unindexed QN (target_node == NULL), but their edge @@ -3835,6 +3884,12 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind). + * Mirrors the sequential twin (pass_usages.c). */ + if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access, + usage->ref_name, res.strategy, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -3963,6 +4018,12 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind). + * Mirrors the sequential twin (pass_usages.c). */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name, + res.strategy, tgt->label)) { + continue; + } const char *etype = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}"); } diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index b51573deb..e29290c74 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -288,6 +288,12 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local, never a package init or an init + * method (cbm_go_suppress_bare_init_bind). Mirrors pass_parallel.c. */ + if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access, + usage->ref_name, res.strategy, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -420,6 +426,13 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) { continue; } + /* `init := func(...)`: a bare Go `init` names a local, never a package + * init or an init method (cbm_go_suppress_bare_init_bind). Mirrors + * pass_parallel.c. */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name, + res.strategy, tgt->label)) { + continue; + } const char *edge_type = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}"); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 2727e41d1..64bc57dba 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -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 ".adr.db" sidecar BEFORE a reindex unlinks the old generation — + * otherwise the row is lost on an upgrade whose first action is a full/format + * reindex. Read-only on the graph DB (migration writes only the sidecar, on its + * own connection). One-time via the sidecar's marker; a no-op when there is no + * legacy row. Returns true on success (safe to delete), false to preserve. */ +static bool adr_migrate_before_delete(const char *db_path) { + /* `db_path` in routing is a staging copy; the live generation (and the + * sidecar the reader looks for) is keyed to the FINAL path — the staging + * suffix stripped. Migrate against that, so the ADR lands in + * ".adr.db", not a discarded stage's sidecar. */ + size_t root_len = stage_root_length(db_path); + char final_path[4096]; + if (root_len >= sizeof(final_path)) { + return false; } -#endif - cbm_store_t *adr_store = cbm_store_open_path_query(db_path); - if (!adr_store) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; + memcpy(final_path, db_path, root_len); + final_path[root_len] = '\0'; + /* The live final DB must exist to carry a legacy ADR forward; if it does + * not (a first index), there is nothing to migrate — succeed. But + * cbm_store_open_path_query returns NULL on an open *failure* too, not only + * a missing file, so only proceed when the file genuinely does not exist; + * a real open failure must preserve-and-abort rather than publish over a + * legacy ADR it never moved. */ + cbm_store_t *mig = cbm_store_open_path_query(final_path); + if (!mig) { + return !cbm_file_exists(final_path); } - cbm_adr_t existing = {0}; - int adr_rc = cbm_store_adr_get(adr_store, p->project_name, &existing); - if (adr_rc == CBM_STORE_NOT_FOUND) { - cbm_store_close(adr_store); - free(p->saved_adr); - p->saved_adr = NULL; - return 0; - } - if (adr_rc != CBM_STORE_OK || !existing.content) { - cbm_store_adr_free(&existing); - cbm_store_close(adr_store); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - char *saved = strdup(existing.content); - cbm_store_adr_free(&existing); - cbm_store_close(adr_store); - if (!saved) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - free(p->saved_adr); - p->saved_adr = saved; - return 0; + int rc = cbm_store_adr_migrate_once(mig); + cbm_store_close(mig); + return rc == CBM_STORE_OK; } static int remove_stage_files(const char *stage_path); @@ -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 ".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); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 34261302d..a759b6821 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -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 diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 35c4f6140..ac2c253af 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -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 ".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); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index d23637332..86ac44cd7 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -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); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index ee1eeb943..997d00194 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1015,6 +1015,46 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s return confidence < CONF_UNIQUE_NAME; } +bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name, + const char *strategy, const char *target_label) { + /* The Go spec gives `init` no binding in the package block: the + * identifier declares init functions and nothing else, and "init + * functions cannot be referred to from anywhere in a program". A method + * or a field named init is reached only through a selector (`x.init()`, + * `e.init`), which arrives here as is_member and is left alone. So a + * BARE `init` is always a local name: a closure (`init := + * func(ctx) {...}; init(ctx)`, 22 kubernetes e2e storage suites), a + * parameter (`func sum(init func() ref.Val)`), or the `package init` + * clause of a directory named init. None of them is a project node. + * + * Every Go Function named init is a package init (no other package-level + * `init` can be declared), so that target is refused for every registry + * strategy. Before #1915 the flat QN made same_module bind the bare name + * to its own package's init; after it, the per-file QN misses there and + * the name fallback binds unrelated init methods instead. A Method or + * Field namesake is refused when only the name found it (the shared weak + * drop-list), which is how every such edge was minted. lsp_* answers are + * untouched: the sequential resolver emits them before its registry + * guards run, so refusing one here would split the two resolvers. + * + * Go-gated: elsewhere `init` is an ordinary name. A bare `init()` in a + * Java, C#, C++, Kotlin or Ruby method calls the class's own method, and + * a C, Python or JS `init` function is called by its bare name. */ + if (!is_go || is_member || !name || strcmp(name, "init") != 0 || !target_label) { + return false; + } + if (strategy && strncmp(strategy, "lsp_", strlen("lsp_")) == 0) { + return false; + } + if (strcmp(target_label, "Function") == 0) { + return true; + } + if (strcmp(target_label, "Method") == 0 || strcmp(target_label, "Field") == 0) { + return weak_short_name_strategy(strategy); + } + return false; +} + /* Import-binding counterpart of the parameter guard above (#2127). An import * binds the identifier for the whole module; when that import is external it * never materializes, and the chain falls through to project-wide short-name diff --git a/src/pipeline/worker_pool.c b/src/pipeline/worker_pool.c index 2943fd746..96611bf8d 100644 --- a/src/pipeline/worker_pool.c +++ b/src/pipeline/worker_pool.c @@ -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); diff --git a/src/pipeline/worker_pool.h b/src/pipeline/worker_pool.h index 91697b961..49b3efd1f 100644 --- a/src/pipeline/worker_pool.h +++ b/src/pipeline/worker_pool.h @@ -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 */ diff --git a/src/store/store.c b/src/store/store.c index c7abb3f0b..ed06236a9 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -183,6 +183,15 @@ struct cbm_store { sqlite3_stmt *stmt_get_file_hashes; sqlite3_stmt *stmt_delete_file_hash; sqlite3_stmt *stmt_delete_file_hashes; + + /* ADRs live in a per-project sidecar DB (".adr.db"), NOT in the + * graph DB, so a reindex that rebuilds/replaces the graph DB never touches + * them (removing the capture/re-apply race entirely). The read-write handle + * is lazily opened on the first ADR write/migration and cached for the + * store's lifetime; NULL for a :memory: store, where ADRs stay in the graph + * DB (never file-swapped, no race). Reads open a short-lived read-only + * handle so a query-only cache is never mutated. */ + sqlite3 *adr_db; }; /* ── Helpers ────────────────────────────────────────────────────── */ @@ -195,6 +204,13 @@ static void store_set_error_sqlite(cbm_store_t *s, const char *prefix) { snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(s->db)); } +/* Like store_set_error_sqlite but reads the message from an explicit + * connection — used for the ADR sidecar DB, whose errors do not live on + * s->db. */ +static void store_set_error_sqlite_on(cbm_store_t *s, sqlite3 *db, const char *prefix) { + snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(db)); +} + static int exec_sql(cbm_store_t *s, const char *sql) { if (!s || !s->db) { return CBM_STORE_ERR; @@ -1530,6 +1546,11 @@ void cbm_store_close(cbm_store_t *s) { * Prevents ASan false-positive leaks from sqlite3 internal state. */ sqlite3_close_v2(s->db); cbm_graph_mem_release(s->graph_mem); + /* ADR sidecar: checkpoint + close its own connection (file stores only). */ + if (s->adr_db) { + (void)sqlite3_wal_checkpoint_v2(s->adr_db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL); + sqlite3_close_v2(s->adr_db); + } safe_str_free(&s->db_path); free(s); } @@ -11923,7 +11944,260 @@ void cbm_adr_sections_free(cbm_adr_sections_t *s) { memset(s, 0, sizeof(*s)); } +/* ── ADR sidecar store ────────────────────────────────────────────── + * ADRs are kept in a per-project sidecar SQLite DB (".adr.db"), so a + * reindex that deletes/replaces the graph DB never disturbs them (no capture, + * re-read or re-apply, no lock). adr_conn() returns the connection ADR ops run + * on: the sidecar for a file-backed store (opened, schema-created and migrated + * once, then cached), or the graph DB for a :memory: store (never file-swapped, + * so there is no race to avoid there). */ + +/* The sidecar's on-disk path (".adr.db"), routed through + * cbm_path_for_file_api so a long path gets the Windows \\?\ prefix (like + * store_open_internal). Returns false for a :memory: store or an over-long + * path. `raw` receives the plain path for existence checks/unlinks; `api` + * receives the file-API form used to open. */ +static bool adr_sidecar_path(const cbm_store_t *s, char *raw, size_t raw_sz, char *api, + size_t api_sz) { + if (!s->db_path) { + return false; + } + int n = snprintf(raw, raw_sz, "%s.adr.db", s->db_path); + if (n <= 0 || (size_t)n >= raw_sz) { + return false; + } + return cbm_path_for_file_api(raw, api, api_sz); +} + +/* Match the graph store's locking so cross-process ADR access waits rather than + * failing: WAL + a 10 s busy timeout (SQLite's own lock wait, not a hand-rolled + * poll). Best-effort: a read-only sidecar rejects journal_mode, which is fine. */ +static void adr_configure(sqlite3 *db) { + (void)sqlite3_exec(db, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL); + (void)sqlite3_exec(db, "PRAGMA journal_mode = WAL;", NULL, NULL, NULL); +} + +static int adr_ensure_schema(sqlite3 *db) { + /* project_summaries has the same shape as the graph DB's, so migration is a + * plain row copy and the cbm_adr_t mapping is unchanged. adr_meta carries a + * one-time "migrated" marker so the legacy copy runs exactly once and can + * never resurrect a row deleted after migration. */ + int rc = sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS project_summaries (" + " project TEXT PRIMARY KEY," + " summary TEXT NOT NULL," + " source_hash TEXT NOT NULL," + " created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL" + ");" + "CREATE TABLE IF NOT EXISTS adr_meta (" + " key TEXT PRIMARY KEY," + " value TEXT NOT NULL" + ");", + NULL, NULL, NULL); + return (rc == SQLITE_OK) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +/* True once the one-time legacy migration has been recorded in the sidecar. */ +static bool adr_migrated(sqlite3 *adb) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(adb, "SELECT 1 FROM adr_meta WHERE key='migrated' LIMIT 1;", + CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { + return false; + } + bool done = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + return done; +} + +/* Copy every ADR row from the graph DB's project_summaries into the sidecar with + * INSERT OR IGNORE (so a row already present locally is never overwritten). Must + * run inside a transaction on the sidecar connection. Returns OK/ERR; does not + * touch the `migrated` marker. */ +static int adr_copy_rows(cbm_store_t *s, sqlite3 *adb) { + sqlite3_stmt *probe = NULL; + if (sqlite3_prepare_v2( + s->db, + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", + CBM_NOT_FOUND, &probe, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + bool has_legacy = sqlite3_step(probe) == SQLITE_ROW; + sqlite3_finalize(probe); + if (!has_legacy) { + return CBM_STORE_OK; /* no source table -> nothing to copy */ + } + + sqlite3_stmt *sel = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT project, summary, source_hash, created_at, updated_at " + "FROM project_summaries;", + CBM_NOT_FOUND, &sel, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_stmt *ins = NULL; + if (sqlite3_prepare_v2(adb, + "INSERT OR IGNORE INTO project_summaries " + "(project, summary, source_hash, created_at, updated_at) " + "VALUES (?1, ?2, ?3, ?4, ?5);", + CBM_NOT_FOUND, &ins, NULL) != SQLITE_OK) { + sqlite3_finalize(sel); + return CBM_STORE_ERR; + } + int rc = SQLITE_DONE; + while ((rc = sqlite3_step(sel)) == SQLITE_ROW) { + for (int c = 0; c < ST_COL_5; c++) { + bind_text(ins, c + 1, (const char *)sqlite3_column_text(sel, c)); + } + int irc = sqlite3_step(ins); + sqlite3_reset(ins); + sqlite3_clear_bindings(ins); + if (irc != SQLITE_DONE) { + sqlite3_finalize(sel); + sqlite3_finalize(ins); + return CBM_STORE_ERR; + } + } + sqlite3_finalize(sel); + sqlite3_finalize(ins); + return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +/* Copy any legacy ADR rows from the graph DB's project_summaries into the + * sidecar (INSERT OR IGNORE, so a local row is never clobbered), then set the + * one-time marker. The marker check, copy and marker insert run in ONE + * BEGIN IMMEDIATE transaction on the sidecar, so two processes migrating at + * once cannot re-copy a row the other just deleted. Returns CBM_STORE_ERR on + * any failure (transaction rolled back) so callers can fail closed and preserve + * the old generation. No-op once the marker is set. */ +static int adr_copy_legacy_once(cbm_store_t *s, sqlite3 *adb) { + if (adr_migrated(adb)) { + return CBM_STORE_OK; /* fast path: already migrated, no lock needed */ + } + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + /* Re-check under the write lock: another process may have migrated between + * the fast-path check and acquiring the lock. */ + if (adr_migrated(adb)) { + (void)sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL); + return CBM_STORE_OK; + } + if (adr_copy_rows(s, adb) != CBM_STORE_OK) { + goto fail; + } + if (sqlite3_exec(adb, "INSERT OR IGNORE INTO adr_meta (key, value) VALUES ('migrated','1');", + NULL, NULL, NULL) != SQLITE_OK) { + goto fail; + } + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + goto fail; + } + return CBM_STORE_OK; + +fail: + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; +} + +/* Restore ADRs from the graph DB into the sidecar for any project the sidecar has + * no row for, WITHOUT overwriting a local one, regardless of the `migrated` + * marker. This is what import needs: an artifact carries its ADR in the graph + * DB's project_summaries, and a teammate who already indexed the project (so the + * sidecar is marked, but has no ADR) must still receive it. INSERT OR IGNORE + * guarantees a local ADR is never clobbered; running ungated by the marker is + * what distinguishes this from the one-time legacy migration. It never writes + * the marker: `adb` comes from adr_conn_write, which hands out a sidecar handle + * only after adr_copy_legacy_once committed it, and nothing deletes it. On + * failure the store error names the step (the import logs it). */ +static int adr_restore_if_absent(cbm_store_t *s, sqlite3 *adb) { + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr restore begin"); + return CBM_STORE_ERR; + } + if (adr_copy_rows(s, adb) != CBM_STORE_OK) { + store_set_error(s, "adr restore copy failed"); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; + } + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr restore commit"); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +/* Open (create) the read-write sidecar, configure locking, ensure the schema, + * run the one-time legacy migration, and cache the handle. NULL + error set on + * failure (fail closed). :memory: stores return the graph DB (no race there). */ +static sqlite3 *adr_conn_write(cbm_store_t *s) { + if (!s || !s->db) { + return NULL; + } + if (!s->db_path) { + return s->db; /* :memory: store — ADRs stay in the graph DB */ + } + if (s->adr_db) { + return s->adr_db; + } + char raw[4096]; + char api[4096]; + if (!adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api))) { + store_set_error(s, "adr sidecar path too long"); + return NULL; + } + sqlite3 *adb = NULL; + int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + if (sqlite3_open_v2(api, &adb, flags, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr sidecar open"); + sqlite3_close_v2(adb); + return NULL; + } + adr_configure(adb); + if (adr_ensure_schema(adb) != CBM_STORE_OK) { + store_set_error_sqlite_on(s, adb, "adr sidecar schema"); + sqlite3_close_v2(adb); + return NULL; + } + if (adr_copy_legacy_once(s, adb) != CBM_STORE_OK) { + store_set_error(s, "adr sidecar legacy migration failed"); + sqlite3_close_v2(adb); + return NULL; + } + s->adr_db = adb; + return s->adr_db; +} + +int cbm_store_adr_migrate_once(cbm_store_t *s) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + if (!s->db_path) { + return CBM_STORE_OK; /* :memory: — ADRs already in the graph DB, nothing to move */ + } + return adr_conn_write(s) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + if (!s->db_path) { + return CBM_STORE_OK; /* :memory: — ADR already lives in the graph DB */ + } + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } + return adr_restore_if_absent(s, adb); +} + int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content) { + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } char now[CBM_SZ_32]; iso_now(now, sizeof(now)); @@ -11933,8 +12207,8 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content "ON CONFLICT(project) DO UPDATE SET summary=excluded.summary, " "updated_at=excluded.updated_at"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_store"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_store"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); @@ -11946,22 +12220,17 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR; } -int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { - if (!s || !s->db || !project || !out) { - return CBM_STORE_ERR; - } - memset(out, 0, sizeof(*out)); - - /* ADR storage was added after the original graph schema. A readable - * legacy generation without project_summaries has no ADR to preserve; it - * is not a read failure and must remain replaceable by a full reindex. */ +/* Read one ADR row from an already-open connection into `out`. Returns + * CBM_STORE_OK / CBM_STORE_NOT_FOUND / CBM_STORE_ERR. Never writes. */ +static int adr_read_row(cbm_store_t *s, sqlite3 *adb, const char *project, cbm_adr_t *out) { + /* A readable legacy generation without project_summaries has no ADR; that is + * NOT_FOUND, not a read failure. */ sqlite3_stmt *table_stmt = NULL; int rc = sqlite3_prepare_v2( - s->db, - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", + adb, "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", CBM_NOT_FOUND, &table_stmt, NULL); if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get table probe"); + store_set_error_sqlite_on(s, adb, "adr_get table probe"); return CBM_STORE_ERR; } rc = sqlite3_step(table_stmt); @@ -11970,26 +12239,26 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { return CBM_STORE_NOT_FOUND; } if (rc != SQLITE_ROW || sqlite3_finalize(table_stmt) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get table probe step"); + store_set_error_sqlite_on(s, adb, "adr_get table probe step"); return CBM_STORE_ERR; } const char *sql = "SELECT project, summary, created_at, updated_at FROM project_summaries " "WHERE project=?1"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_get"); return CBM_STORE_ERR; } if (bind_text(stmt, SKIP_ONE, project) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get bind"); + store_set_error_sqlite_on(s, adb, "adr_get bind"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } rc = sqlite3_step(stmt); if (rc != SQLITE_ROW) { if (rc != SQLITE_DONE) { - store_set_error_sqlite(s, "adr_get step"); + store_set_error_sqlite_on(s, adb, "adr_get step"); } sqlite3_finalize(stmt); if (rc == SQLITE_DONE) { @@ -12005,13 +12274,11 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { cbm_store_adr_free(out); - store_set_error_sqlite(s, "adr_get finalize"); + store_set_error_sqlite_on(s, adb, "adr_get finalize"); return CBM_STORE_ERR; } - - /* Every selected column is NOT NULL in the schema. A NULL here therefore - * means either allocation failure or corrupt data; never return a partial - * ADR that a full rebuild could silently drop during publication. */ + /* Every selected column is NOT NULL in the schema. A NULL here means an + * allocation failure or corruption; never return a partial ADR. */ if (!out->project || !out->content || !out->created_at || !out->updated_at) { cbm_store_adr_free(out); store_set_error(s, "adr_get: failed to copy complete ADR"); @@ -12020,16 +12287,62 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { return CBM_STORE_OK; } +int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { + if (!s || !s->db || !project || !out) { + return CBM_STORE_ERR; + } + memset(out, 0, sizeof(*out)); + + /* :memory: store, or a sidecar already open read-write: read it directly. */ + if (!s->db_path) { + return adr_read_row(s, s->db, project, out); + } + if (s->adr_db) { + return adr_read_row(s, s->adr_db, project, out); + } + + /* A read must never create the sidecar (a query-only cache may be + * read-only): open it READONLY if the file exists, otherwise read the + * legacy row from the graph DB without migrating. */ + char raw[4096]; + char api[4096]; + if (adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api)) && cbm_file_exists(raw)) { + sqlite3 *ro = NULL; + if (sqlite3_open_v2(api, &ro, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, ro, "adr sidecar open (ro)"); + sqlite3_close_v2(ro); + return CBM_STORE_ERR; + } + (void)sqlite3_exec(ro, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL); + /* A sidecar that exists but was never marked migrated is half-created: + * the legacy copy did not commit, so the ADR (if any) still lives only + * in the graph DB. Fall back to the graph row so a partial sidecar never + * hides an existing ADR. */ + if (!adr_migrated(ro)) { + sqlite3_close_v2(ro); + return adr_read_row(s, s->db, project, out); + } + int rc = adr_read_row(s, ro, project, out); + sqlite3_close_v2(ro); + return rc; + } + return adr_read_row(s, s->db, project, out); +} + int cbm_store_adr_delete(cbm_store_t *s, const char *project) { + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } const char *sql = "DELETE FROM project_summaries WHERE project=?1"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_delete"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_delete"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); int rc = sqlite3_step(stmt); - int changes = sqlite3_changes(s->db); + int changes = sqlite3_changes(adb); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { return CBM_STORE_ERR; @@ -12047,13 +12360,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha return CBM_STORE_ERR; } - /* The read-modify-write below must be ONE transaction. Three writers - * replace this row wholesale — the indexing pipeline, the UI POST - * /api/adr handler, and manage_adr mode='update' — so an unguarded - * get/merge/store silently loses whichever of them commits between the - * read and the UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a - * competing writer waits rather than being overwritten. */ - if (cbm_store_begin(s) != CBM_STORE_OK) { + /* The read-modify-write below must be ONE transaction, and it runs on the + * ADR connection (the sidecar for a file store, the graph DB for :memory:) + * — the same connection cbm_store_adr_get/store use, so the write lock + * actually covers them. Two writers replace this row wholesale — the UI + * POST /api/adr handler and manage_adr mode='update' — so an unguarded + * get/merge/store silently loses whichever commits between the read and the + * UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a competing + * writer waits rather than being overwritten. */ + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_update begin"); return CBM_STORE_ERR; } @@ -12061,7 +12381,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha cbm_adr_t existing; int rc = cbm_store_adr_get(s, project, &existing); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, "no existing ADR to update"); return rc; } @@ -12076,7 +12396,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha if (cbm_adr_check_structure(existing.content, structure_err, (int)sizeof(structure_err)) != CBM_STORE_OK) { cbm_store_adr_free(&existing); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, structure_err); return CBM_STORE_ERR; } @@ -12090,7 +12410,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha } if (!merged) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, "failed to splice ADR section"); return CBM_STORE_ERR; } @@ -12101,7 +12421,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha snprintf(msg, sizeof(msg), "merged ADR exceeds %d chars (%d chars)", CBM_ADR_MAX_LENGTH, (int)strlen(merged)); free(merged); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, msg); return CBM_STORE_ERR; } @@ -12110,19 +12430,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha rc = cbm_store_adr_store(s, project, merged); free(merged); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return rc; } /* Read back INSIDE the transaction so `out` is exactly what commits. */ rc = cbm_store_adr_get(s, project, out); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return rc; } - if (cbm_store_commit(s) != CBM_STORE_OK) { + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_update commit"); cbm_store_adr_free(out); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return CBM_STORE_ERR; } return CBM_STORE_OK; diff --git a/src/store/store.h b/src/store/store.h index 63379ad94..eed6f746d 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -1163,6 +1163,17 @@ typedef struct { int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content); int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out); +/* Move any legacy ADR row from the graph DB's project_summaries into the + * per-project sidecar, once (guarded by a marker in the sidecar). Call before a + * rebuild deletes the old generation, and on artifact import. Returns + * CBM_STORE_ERR on failure so callers can preserve the old generation. */ +int cbm_store_adr_migrate_once(cbm_store_t *s); +/* Restore ADRs from the graph DB's project_summaries into the per-project sidecar + * for any project the sidecar has no row for, without overwriting a local one and + * regardless of the migrated marker. Used on artifact import so a teammate who + * already indexed the project (marked sidecar, no ADR) still receives the shared + * ADR the artifact carries. Returns CBM_STORE_ERR on failure. */ +int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s); int cbm_store_adr_delete(cbm_store_t *s, const char *project); int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const char **keys, const char **values, int count, cbm_adr_t *out); diff --git a/tests/test_artifact.c b/tests/test_artifact.c index 8bbec658d..abf1415da 100644 --- a/tests/test_artifact.c +++ b/tests/test_artifact.c @@ -2,6 +2,7 @@ * test_artifact.c — Tests for persistent artifact export/import. */ #include "test_framework.h" +#include "sqlite3.h" /* vendored/sqlite3 — seed a legacy project_summaries row in ADR tests */ #include "store/store.h" #include "pipeline/artifact.h" #include "pipeline/pipeline.h" @@ -64,6 +65,21 @@ static void cleanup_dir(const char *path) { (void)system(cmd); } +/* Remove a destination graph DB (+ its -wal/-shm/-journal) while leaving the ADR + * sidecar ".adr.db" in place. Import only runs when the graph DB is missing + * (try_artifact_bootstrap); this mirrors that shape so the import rename does not + * hit an existing file (which Windows refuses, unlike POSIX). */ +static void remove_graph_db_keep_sidecar(const char *db) { + char p[1200]; + (void)cbm_unlink(db); + snprintf(p, sizeof(p), "%s-wal", db); + (void)cbm_unlink(p); + snprintf(p, sizeof(p), "%s-shm", db); + (void)cbm_unlink(p); + snprintf(p, sizeof(p), "%s-journal", db); + (void)cbm_unlink(p); +} + static void write_text_file(const char *path, const char *text) { FILE *fp = fopen(path, "w"); if (!fp) { @@ -206,6 +222,226 @@ TEST(artifact_export_fast_roundtrip) { PASS(); } +/* The ADR travels with the exported artifact: it lives in the ".adr.db" + * sidecar, and export must carry it into graph.db.zst so an import restores it + * into the destination's sidecar (round-2 review item 7d). */ +TEST(artifact_export_roundtrip_keeps_adr) { + setup_artifact_test(); + create_test_db(g_db); + + const char *adr_text = "# Decision\nThe team chose the sidecar design."; + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", adr_text), CBM_STORE_OK); + cbm_store_close(src); + + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* The imported project starts with the team's ADR (restored into the + * destination sidecar by import). */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + /* The fresh destination sidecar is marked migrated after the import, so a + * later reindex's migrate-before-delete is a no-op. Opening the sidecar for + * the restore already commits the marker; the restore itself adds none. */ + char side[1100]; + snprintf(side, sizeof(side), "%s.adr.db", import_db); + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open_v2(side, &raw, SQLITE_OPEN_READONLY, NULL), SQLITE_OK); + sqlite3_stmt *st = NULL; + ASSERT_EQ( + sqlite3_prepare_v2(raw, "SELECT value FROM adr_meta WHERE key='migrated';", -1, &st, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(st), SQLITE_ROW); + sqlite3_finalize(st); + sqlite3_close(raw); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* Regression: a stale legacy `project_summaries` row that lingers in the graph DB + * after its ADR was migrated then DELETED must NOT resurrect through an export. + * With the sidecar empty, the snapshot must ship an empty table, so an import + * finds no ADR. (Before the DELETE-first fix, VACUUM INTO copied the stale row + * and import restored a deleted decision record.) */ +TEST(artifact_export_without_adr_does_not_resurrect_legacy_row) { + setup_artifact_test(); + create_test_db(g_db); + + /* Pre-sidecar layout: an ADR lingering only in the graph DB. */ + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(g_db, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + "test-proj", "# Decision\nstale, later deleted"); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* Migrate it into the sidecar (marker set), then DELETE it. The graph row + * still lingers; the sidecar is now migrated-but-empty. */ + cbm_store_t *s = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_adr_migrate_once(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_delete(s, "test-proj"), CBM_STORE_OK); + cbm_store_close(s); + + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* An import must not clobber an ADR already present locally: the restore uses + * INSERT OR IGNORE, so the teammate's own decision record survives importing a + * shared artifact. */ +TEST(artifact_import_does_not_clobber_local_adr) { + setup_artifact_test(); + create_test_db(g_db); + + /* The shared artifact carries ADR "A". */ + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + /* The importing machine already has its own local ADR "B" for this project. */ + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + cbm_store_t *local = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(local); + ASSERT_EQ(cbm_store_adr_store(local, "test-proj", "# Decision\nlocal B"), CBM_STORE_OK); + cbm_store_close(local); + + /* Import only runs when the graph DB is missing (try_artifact_bootstrap), and + * Windows cannot rename over an existing file. Remove the destination graph DB + * (keeping the sidecar + its marker) to match production. */ + remove_graph_db_keep_sidecar(import_db); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* Local B is preserved — the import did not overwrite it with shared A. */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, "# Decision\nlocal B"); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* The shared ADR must reach a teammate who has ALREADY indexed the project: their + * sidecar is marked migrated but has no ADR row, so the marker-gated migrate-once + * would skip it. Import restores-if-absent (ungated), so the artifact's ADR "A" + * arrives. (On main, import replaced the whole DB, so the ADR always came through; + * the sidecar must preserve that.) */ +TEST(artifact_import_restores_adr_when_local_absent) { + setup_artifact_test(); + create_test_db(g_db); + + /* The shared artifact carries ADR "A". */ + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + /* The destination is already indexed: a marked sidecar with NO ADR row. */ + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + create_test_db(import_db); + cbm_store_t *pre = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(pre); + ASSERT_EQ(cbm_store_adr_migrate_once(pre), CBM_STORE_OK); /* marks the sidecar, no ADR */ + cbm_adr_t none = {0}; + ASSERT_EQ(cbm_store_adr_get(pre, "test-proj", &none), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&none); + cbm_store_close(pre); + + /* Import only runs when the graph DB is missing; remove it (keep the marked + * sidecar) so the rename succeeds on Windows too. */ + remove_graph_db_keep_sidecar(import_db); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* The shared ADR "A" is restored despite the pre-existing marker. */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, "# Decision\nshared A"); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* A failed ADR restore must not be silent (#2315 follow-up). A DIRECTORY at + * ".adr.db" makes the sidecar open fail deterministically (no hook). The + * graph DB is already installed when the restore runs, so the import still + * succeeds (best-effort, unchanged), but the team ADR that did not arrive is + * logged with the store's reason instead of being dropped without a trace. */ +TEST(artifact_import_logs_failed_adr_restore) { + setup_artifact_test(); + create_test_db(g_db); + + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + char adr_dir[1100]; + snprintf(adr_dir, sizeof(adr_dir), "%s.adr.db", import_db); + ASSERT_EQ(cbm_mkdir(adr_dir), 0); + + capture_logs_start(); + int rc = cbm_artifact_import(g_repo, import_db); + const char *logs = capture_logs_end(); + + ASSERT_EQ(rc, 0); /* the graph DB is installed; the restore stays best-effort */ + ASSERT_TRUE(cbm_file_exists(import_db)); + const char *line = strstr(logs, "level=warn msg=artifact.import err=adr_restore"); + ASSERT_NOT_NULL(line); + /* The store's reason is carried (the text log format maps spaces to '_'). */ + ASSERT(strstr(line, "detail=adr_sidecar_open") != NULL); + + cleanup_dir(g_tmpdir); + PASS(); +} + TEST(artifact_export_best_roundtrip) { setup_artifact_test(); create_test_db(g_db); @@ -1172,6 +1408,11 @@ SUITE(artifact) { RUN_TEST(artifact_repo_path_shell_safe_rejects_injection); RUN_TEST(artifact_repo_path_shell_safe_rejects_cmd_metachars_on_windows); RUN_TEST(artifact_export_fast_roundtrip); + RUN_TEST(artifact_export_roundtrip_keeps_adr); + RUN_TEST(artifact_export_without_adr_does_not_resurrect_legacy_row); + RUN_TEST(artifact_import_does_not_clobber_local_adr); + RUN_TEST(artifact_import_restores_adr_when_local_absent); + RUN_TEST(artifact_import_logs_failed_adr_restore); RUN_TEST(artifact_export_best_roundtrip); RUN_TEST(artifact_exists_check); RUN_TEST(artifact_commit_hash); diff --git a/tests/test_cli.c b/tests/test_cli.c index 93af5d3f0..44f84161c 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -6180,6 +6180,149 @@ TEST(cli_zed_mcp_jsonc_comments) { PASS(); } +#ifndef _WIN32 +TEST(cli_install_agent_config_symlinks_issue2671) { + /* Refs #2671: dotfiles checkout pattern where agent config directory is a + * user-owned symlink into dotfiles under the user's home directory. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-symlink-test-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char dotfiles_zed[512]; + snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir); + test_mkdirp(dotfiles_zed); + char dotfiles_settings[512]; + snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed); + write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n"); + +#ifdef __APPLE__ + char parent_dir[512]; + snprintf(parent_dir, sizeof(parent_dir), "%s/Library/Application Support", tmpdir); + test_mkdirp(parent_dir); + char zed_link[512]; + snprintf(zed_link, sizeof(zed_link), "%s/Zed", parent_dir); +#else + char parent_dir[512]; + snprintf(parent_dir, sizeof(parent_dir), "%s/.config", tmpdir); + test_mkdirp(parent_dir); + char zed_link[512]; + snprintf(zed_link, sizeof(zed_link), "%s/zed", parent_dir); +#endif + + char *saved_xdg = save_test_env("XDG_CONFIG_HOME"); + char xdg_dir[640]; + snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir); + cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */ + + /* Dangling symlink and symlink to a regular file must remain undetected. */ + char dangling_target[512]; + snprintf(dangling_target, sizeof(dangling_target), "%s/dotfiles/missing-zed", tmpdir); + ASSERT_EQ(symlink(dangling_target, zed_link), 0); + bool dangling_detected = cbm_detect_agents(tmpdir).zed; + ASSERT_EQ(cbm_unlink(zed_link), 0); + + ASSERT_EQ(symlink(dotfiles_settings, zed_link), 0); + bool file_link_detected = cbm_detect_agents(tmpdir).zed; + ASSERT_EQ(cbm_unlink(zed_link), 0); + + /* Symlink resolving to a directory not owned by the invoking user must be refused. */ + char foreign_dir[512]; + const char *unowned_target = "/"; + if (geteuid() == 0) { + snprintf(foreign_dir, sizeof(foreign_dir), "%s/dotfiles/foreign-zed", tmpdir); + test_mkdirp(foreign_dir); + ASSERT_EQ(chown(foreign_dir, 65534, 65534), 0); + unowned_target = foreign_dir; + } + ASSERT_EQ(symlink(unowned_target, zed_link), 0); + bool unowned_target_detected = cbm_detect_agents(tmpdir).zed; + ASSERT_EQ(cbm_unlink(zed_link), 0); + + ASSERT_EQ(symlink(dotfiles_zed, zed_link), 0); + + cbm_cli_set_client_selection_for_testing("zed"); + int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false); + cbm_cli_set_client_selection_for_testing(NULL); + restore_test_env("XDG_CONFIG_HOME", saved_xdg); + + char *after = read_test_file_alloc(dotfiles_settings); + bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL; + bool has_original = after && strstr(after, "Ayu Dark") != NULL; + free(after); + + struct stat st; + ASSERT_EQ(lstat(zed_link, &st), 0); + bool still_symlink = S_ISLNK(st.st_mode); + + test_rmdir_r(tmpdir); + ASSERT(!dangling_detected); + ASSERT(!file_link_detected); + ASSERT(!unowned_target_detected); + ASSERT_EQ(rc, 0); + ASSERT(has_cbm); + ASSERT(has_original); + ASSERT(still_symlink); + PASS(); +} + +TEST(cli_install_agent_config_file_symlink_issue2671) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-file-symlink-test-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char dotfiles_zed[512]; + snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir); + test_mkdirp(dotfiles_zed); + char dotfiles_settings[512]; + snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed); + write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n"); + +#ifdef __APPLE__ + char zed_dir[512]; + snprintf(zed_dir, sizeof(zed_dir), "%s/Library/Application Support/Zed", tmpdir); + test_mkdirp(zed_dir); + char zed_settings_link[512]; + snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir); + ASSERT_EQ(symlink("../../../dotfiles/zed/settings.json", zed_settings_link), 0); +#else + char zed_dir[512]; + snprintf(zed_dir, sizeof(zed_dir), "%s/.config/zed", tmpdir); + test_mkdirp(zed_dir); + char zed_settings_link[512]; + snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir); + ASSERT_EQ(symlink("../../dotfiles/zed/settings.json", zed_settings_link), 0); +#endif + + char *saved_xdg = save_test_env("XDG_CONFIG_HOME"); + char xdg_dir[640]; + snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir); + cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */ + + cbm_cli_set_client_selection_for_testing("zed"); + int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false); + cbm_cli_set_client_selection_for_testing(NULL); + restore_test_env("XDG_CONFIG_HOME", saved_xdg); + + char *after = read_test_file_alloc(dotfiles_settings); + bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL; + bool has_original = after && strstr(after, "Ayu Dark") != NULL; + free(after); + + struct stat st; + ASSERT_EQ(lstat(zed_settings_link, &st), 0); + bool still_symlink = S_ISLNK(st.st_mode); + + test_rmdir_r(tmpdir); + ASSERT_EQ(rc, 0); + ASSERT(has_cbm); + ASSERT(has_original); + ASSERT(still_symlink); + PASS(); +} +#endif + /* ═══════════════════════════════════════════════════════════════════ * PATH management tests (port of TestCLI_InstallPATHAppend) * ═══════════════════════════════════════════════════════════════════ */ @@ -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); diff --git a/tests/test_conditional_variants.c b/tests/test_conditional_variants.c index 5d5aa1716..a8ced098c 100644 --- a/tests/test_conditional_variants.c +++ b/tests/test_conditional_variants.c @@ -402,6 +402,45 @@ TEST(cv_elixir_compile_time_if) { PASS(); } +/* A multi-clause function under a compile-time `if`. Each branch's clauses + * fold into one definition spanning them (the clause fold), and the two + * branches stay two variants of one identity: exactly the spans 5-6 and 8-9, + * neither one span per clause (no fold) nor one span over both branches (a + * fold across the branch boundary). */ +TEST(cv_elixir_multi_clause_under_if) { + static const cv_file_t files[] = {{"pick.ex", "defmodule Pick do\n" /* 1 */ + " def alpha(_), do: :ok\n" /* 2 */ + " def beta(_), do: :ok\n" /* 3 */ + " if Code.ensure_loaded?(:win32) do\n" /* 4 */ + " def pick(0), do: alpha(0)\n" /* 5 */ + " def pick(n), do: alpha(n)\n" /* 6 */ + " else\n" /* 7 */ + " def pick(0), do: beta(0)\n" /* 8 */ + " def pick(n), do: beta(n)\n" /* 9 */ + " end\n" /* 10 */ + " def caller, do: pick(1)\n" /* 11 */ + "end\n"}}; /* 12 */ + cv_proj_t p; + ASSERT_TRUE(cv_index(&p, files, 1)); + cv_result_t r = cv_measure(&p, "pick", "alpha", "beta", "caller"); + const char *span_do = + "SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v " + "WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS + " AND json_extract(v.value,'$.start_line')=5 AND json_extract(v.value,'$.end_line')=6"; + const char *span_else = + "SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v " + "WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS + " AND json_extract(v.value,'$.start_line')=8 AND json_extract(v.value,'$.end_line')=9"; + int do_spans = cv_count(&p, span_do, "pick", NULL); + int else_spans = cv_count(&p, span_else, "pick", NULL); + cv_cleanup(&p); + ASSERT_TRUE(cv_expect("elixir_multi_clause", r)); + ASSERT_EQ(r.variant_spans, 2); + ASSERT_EQ(do_spans, 1); + ASSERT_EQ(else_spans, 1); + PASS(); +} + /* Verilog: the identity only. Module instantiation produces no CALLS edge in * this graph even without any `ifdef (a general Verilog gap, not a variant * one), so the body and caller edges are not this suite's to assert. */ @@ -798,6 +837,7 @@ SUITE(conditional_variants) { RUN_TEST(cv_pascal_ifdef); RUN_TEST(cv_julia_static_if); RUN_TEST(cv_elixir_compile_time_if); + RUN_TEST(cv_elixir_multi_clause_under_if); RUN_TEST(cv_verilog_ifdef); RUN_TEST(cv_c_platform_files); RUN_TEST(cv_c_platform_directories); diff --git a/tests/test_config_text_edit.c b/tests/test_config_text_edit.c index 4ce474853..45331d89c 100644 --- a/tests/test_config_text_edit.c +++ b/tests/test_config_text_edit.c @@ -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); diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 9d318f8c0..0bf14c68b 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -944,10 +944,201 @@ TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) { PASS(); } +static bool cross_repo_seed_server_route(const cross_repo_fixture_t *fixture, + const char *target_project, const char *method, + const char *route_path, const char *handler_name) { + char target_path[512]; + if (!cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) { + return false; + } + cbm_store_t *target = cbm_store_open_path(target_path); + if (!target) { + return false; + } + bool ok = cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK; + char target_route_qn[256]; + char handler_qn[256]; + char route_name[128]; + snprintf(target_route_qn, sizeof(target_route_qn), "__route__%s__%s", method, route_path); + snprintf(handler_qn, sizeof(handler_qn), "%s.%s", target_project, handler_name); + snprintf(route_name, sizeof(route_name), "%s %s", method, route_path); + + cbm_node_t target_route = {.project = target_project, + .label = "Route", + .name = route_name, + .qualified_name = target_route_qn, + .file_path = "server.java"}; + cbm_node_t handler = {.project = target_project, + .label = "Function", + .name = handler_name, + .qualified_name = handler_qn, + .file_path = "server.java"}; + int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; + int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; + cbm_edge_t handles = {.project = target_project, + .source_id = handler_id, + .target_id = target_route_id, + .type = "HANDLES"}; + ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; + cbm_store_close(target); + return ok; +} + +static bool cross_repo_seed_client_call(const cross_repo_fixture_t *fixture, + const char *source_project, const char *caller_name, + const char *callee_name, const char *url_path, + const char *method) { + char source_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path(source_path); + if (!source) { + return false; + } + bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK; + char caller_qn[256]; + char local_route_qn[256]; + char edge_props[512]; + snprintf(caller_qn, sizeof(caller_qn), "%s.%s", source_project, caller_name); + snprintf(local_route_qn, sizeof(local_route_qn), "__route__%s__%s", method ? method : "ANY", + url_path); + if (method) { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"method\":\"%s\"}", callee_name, + url_path, method); + } else { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\"}", callee_name, url_path); + } + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = caller_name, + .qualified_name = caller_qn, + .file_path = "item.service.ts"}; + cbm_node_t local_route = {.project = source_project, + .label = "Route", + .name = url_path, + .qualified_name = local_route_qn, + .file_path = "item.service.ts"}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0; + cbm_edge_t http_call = {.project = source_project, + .source_id = caller_id, + .target_id = local_route_id, + .type = "HTTP_CALLS", + .properties_json = edge_props}; + ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0; + cbm_store_close(source); + return ok; +} + +static int cross_repo_count_edges_like(const cross_repo_fixture_t *fixture, const char *project, + const char *edge_type, const char *pattern) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return -1; + } + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) { + return -1; + } + sqlite3_stmt *s = NULL; + int count = -1; + if (sqlite3_prepare_v2( + cbm_store_get_db(store), + "SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2 AND properties LIKE ?3", + -1, &s, NULL) == SQLITE_OK) { + sqlite3_bind_text(s, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 2, edge_type, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 3, pattern, -1, SQLITE_STATIC); + if (sqlite3_step(s) == SQLITE_ROW) { + count = sqlite3_column_int(s, 0); + } + sqlite3_finalize(s); + } + cbm_store_close(store); + return count; +} + +/* #2768: Angular HttpClient calls must match cross-repo routes with exact HTTP verbs. + * When client makes GET requests to /items/list, /items/42, and /items/job/10, + * and the server defines overlapping routes: + * DELETE /items/{id} (seeded first in DB) + * GET /items/{id} (seeded second in DB) + * POST /items/{parentId}/{id} (seeded first in DB) + * GET /items/job/{jobId} (seeded second in DB) + * the fuzzy route matcher must enforce method compatibility, linking the GET calls + * to their respective GET endpoints and never to DELETE or POST endpoints. + * getItem and getJob are seeded WITHOUT a method property (an edge written by an + * index that did not record the verb): the matcher must infer GET from the + * callee's verb suffix, or the method-less fuzzy match lands on the DELETE / POST + * route seeded first. getItems keeps its explicit "GET". */ +TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + /* Target server with overlapping endpoints */ + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/list", + "handle_list_items") && + cross_repo_seed_server_route(&fixture, "api-2768", "DELETE", "/items/{}", + "handle_delete_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/{}", + "handle_get_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "POST", "/items/{}/{}", + "handle_post_nested_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/job/{}", + "handle_get_job") && + /* Angular client making GET requests */ + cross_repo_seed_client_call(&fixture, "angular-2768", "getItems", + "this.http.get", "/items/list", "GET") && + cross_repo_seed_client_call(&fixture, "angular-2768", "getItem", + "this.http.get", "/items/42", NULL) && + cross_repo_seed_client_call(&fixture, "angular-2768", "getJob", + "this.http.get", "/items/job/10", NULL); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed issue 2768 fixture"); + } + + const char *target = "api-2768"; + cbm_cross_repo_result_t fwd = cbm_cross_repo_match("angular-2768", &target, 1); + ASSERT_FALSE(fwd.failed); + ASSERT_EQ(fwd.http_edges, 3); + + /* Assert that client CROSS_HTTP_CALLS edges are linked to GET handlers, not DELETE or POST */ + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_delete_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_post_nested_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_list_items%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_item%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_job%"), + 1); + + /* Reverse pass */ + const char *consumer = "angular-2768"; + cbm_cross_repo_result_t rev = cbm_cross_repo_match("api-2768", &consumer, 1); + ASSERT_FALSE(rev.failed); + ASSERT_EQ(rev.http_edges, 3); + + cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); + PASS(); +} + SUITE(cross_repo) { RUN_TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459); RUN_TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459); RUN_TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459); + RUN_TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768); RUN_TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609); RUN_TEST(cross_repo_null_target_fails_without_dereference); RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens); diff --git a/tests/test_cs_lsp.c b/tests/test_cs_lsp.c index 6c111ca2d..c6a58c474 100644 --- a/tests/test_cs_lsp.c +++ b/tests/test_cs_lsp.c @@ -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); } diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 7dc68db66..ad8d98441 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -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); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 0a15bc48a..541b7bb2d 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -100,6 +100,47 @@ static int count_defs_named(CBMFileResult *r, const char *label, const char *nam return count; } +/* Count calls to `callee` whose enclosing scope is exactly `scope_qn`. A call the + * scope walk failed to place carries the FILE or module QN here, so a bare count + * of the callee passes just as happily with every call homed on the file. */ +static int count_calls_from(CBMFileResult *r, const char *callee, const char *scope_qn) { + int count = 0; + for (int i = 0; i < r->calls.count; i++) { + if (r->calls.items[i].callee_name && r->calls.items[i].enclosing_func_qn && + strcmp(r->calls.items[i].callee_name, callee) == 0 && + strcmp(r->calls.items[i].enclosing_func_qn, scope_qn) == 0) { + count++; + } + } + return count; +} + +/* Count usages of `ref` attributed to exactly `scope_qn`, and to any scope. A + * def head is a BINDING, not a usage, so the pair separates two different + * mistakes: too few rows means a read was swallowed by the binding, too many + * means a binding site was counted as a read. */ +static int count_usages_from(CBMFileResult *r, const char *ref, const char *scope_qn) { + int count = 0; + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && r->usages.items[i].enclosing_func_qn && + strcmp(r->usages.items[i].ref_name, ref) == 0 && + strcmp(r->usages.items[i].enclosing_func_qn, scope_qn) == 0) { + count++; + } + } + return count; +} + +static int count_usages_named(CBMFileResult *r, const char *ref) { + int count = 0; + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, ref) == 0) { + count++; + } + } + return count; +} + static int count_calls_named(CBMFileResult *r, const char *callee) { int count = 0; for (int i = 0; i < r->calls.count; i++) { @@ -1896,6 +1937,730 @@ TEST(elixir_function) { PASS(); } +/* ── Elixir def-head guard helpers (internal/cbm/helpers.c) ───────── + * + * `def name(args) when guard` parses its WHOLE head as one `when` + * binary_operator whose left operand is the `name(args)` call, and Elixir + * admits more than one guard on a clause, which tree-sitter-elixir nests + * right-associatively. Four walks read that same first argument and each asks a + * different question of it: the defs and call-scope walks want the head UNDER + * every guard, the usages walk wants it so that a parameter READ by the guard + * stays outside the binding, and the calls walk wants to know whether the node + * it is visiting IS that head or one of the guard operators above it. + * + * That last question is why cbm_elixir_def_head_is exists beside the unwrap, + * and it is exercised here on the AST rather than through cbm_extract_file + * because the two routes it has to answer for are indistinguishable from + * outside: `def f(x) when g` reaches the calls walk as the inner `f(x)` call, + * which the unwrap reaches, while `def f when g` has no inner call at all and + * reaches it as the `when` operator itself, which the unwrap does not. A test + * that only drives one walk sees whichever of the two that walk happens to hit. + * + * This file's helpers change no extraction behaviour on their own; the walks + * that consume them are separate changes. */ + +enum { ELIXIR_HEAD_PROBE_MAX = 16, ELIXIR_HEAD_STACK_MAX = 256 }; + +static bool head_probe_text_is(const char *source, TSNode node, const char *want) { + if (ts_node_is_null(node)) { + return false; + } + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + size_t len = strlen(want); + return end >= start && (size_t)(end - start) == len && memcmp(source + start, want, len) == 0; +} + +/* The first argument of every `def`/`defp` call under `root`, in source order. + * That argument is what all four walks read, so it is what the helpers take. */ +typedef struct { + TSNode signature[ELIXIR_HEAD_PROBE_MAX]; + int count; +} elixir_head_probe_t; + +static void collect_elixir_def_signatures(TSNode root, const char *source, + elixir_head_probe_t *out) { + TSNode stack[ELIXIR_HEAD_STACK_MAX]; + int top = 0; + stack[top++] = root; + while (top > 0) { + TSNode cur = stack[--top]; + uint32_t cc = ts_node_child_count(cur); + if (cc > 1 && strcmp(ts_node_type(cur), "call") == 0 && + (head_probe_text_is(source, ts_node_child(cur, 0), "def") || + head_probe_text_is(source, ts_node_child(cur, 0), "defp")) && + out->count < ELIXIR_HEAD_PROBE_MAX) { + TSNode args = ts_node_child(cur, 1); + if (!ts_node_is_null(args) && ts_node_child_count(args) > 0) { + out->signature[out->count++] = ts_node_child(args, 0); + } + } + for (int i = (int)cc - 1; i >= 0 && top < ELIXIR_HEAD_STACK_MAX; i--) { + stack[top++] = ts_node_child(cur, (uint32_t)i); + } + } +} + +TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it) { + const char *source = "defmodule Heads do\n" + " def plain(x), do: x\n" + " def guarded(x) when is_binary(x), do: x\n" + " def bare when true, do: :ok\n" + " def twice(x) when is_atom(x) when is_binary(x), do: x\n" + " def a + b, do: {a, b}\n" + "end\n"; + const TSLanguage *language = cbm_ts_language(CBM_LANG_ELIXIR); + ASSERT_NOT_NULL(language); + TSParser *parser = ts_parser_new(); + ASSERT_NOT_NULL(parser); + ASSERT_TRUE(ts_parser_set_language(parser, language)); + TSTree *tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)strlen(source)); + ASSERT_NOT_NULL(tree); + + elixir_head_probe_t probe; + probe.count = 0; + collect_elixir_def_signatures(ts_tree_root_node(tree), source, &probe); + ASSERT_EQ(5, probe.count); + + /* Unguarded: nothing to peel, and the head is the whole first argument. + * The name node inside it is NOT the head -- the calls walk compares the + * node it is visiting, and the `plain` identifier is one of those. */ + TSNode plain = probe.signature[0]; + ASSERT_FALSE(cbm_elixir_is_when_guard(plain)); + ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(plain), plain)); + ASSERT_TRUE(cbm_elixir_def_head_is(plain, plain)); + ASSERT_FALSE(cbm_elixir_def_head_is(plain, ts_node_child(plain, 0))); + + /* One guard: the first argument is the operator, the head is its left + * operand, and BOTH answer true -- the operator is the node the paren-less + * route hands the calls walk, the inner call is the node the peel route + * hands it. The guard expression answers false, or the walk would suppress + * a real call written inside a guard. */ + TSNode guarded = probe.signature[1]; + ASSERT_TRUE(cbm_elixir_is_when_guard(guarded)); + TSNode guarded_head = cbm_elixir_def_head_unwrap_guard(guarded); + ASSERT_FALSE(ts_node_eq(guarded_head, guarded)); + ASSERT_STR_EQ("call", ts_node_type(guarded_head)); + ASSERT_TRUE(head_probe_text_is(source, guarded_head, "guarded(x)")); + ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded)); + ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded_head)); + TSNode guard_expr = ts_node_child_by_field_name(guarded, TS_FIELD("right")); + ASSERT_FALSE(ts_node_is_null(guard_expr)); + ASSERT_TRUE(head_probe_text_is(source, guard_expr, "is_binary(x)")); + ASSERT_FALSE(cbm_elixir_def_head_is(guarded, guard_expr)); + + /* Paren-less: the head under the guard is a bare identifier, so there is no + * inner call for the peel route to produce. This is the shape that makes + * cbm_elixir_def_head_is necessary: the only node the calls walk ever sees + * for this clause is the operator. */ + TSNode bare = probe.signature[2]; + ASSERT_TRUE(cbm_elixir_is_when_guard(bare)); + TSNode bare_head = cbm_elixir_def_head_unwrap_guard(bare); + ASSERT_STR_EQ("identifier", ts_node_type(bare_head)); + ASSERT_TRUE(head_probe_text_is(source, bare_head, "bare")); + ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare)); + ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare_head)); + + /* Two guards on one clause. tree-sitter-elixir nests them + * RIGHT-associatively -- `when(twice(x), when(is_atom(x), is_binary(x)))`, + * which is what this asserts -- so the head is still the outer operator's + * left operand and the peel reaches it in one step. The repeated peel is + * therefore a fixed point rather than a chain walk on this grammar; the + * nodes that must answer true are the operator and the head. */ + TSNode twice = probe.signature[3]; + ASSERT_TRUE(cbm_elixir_is_when_guard(twice)); + TSNode second_guard = ts_node_child_by_field_name(twice, TS_FIELD("right")); + ASSERT_TRUE(cbm_elixir_is_when_guard(second_guard)); + TSNode twice_head = cbm_elixir_def_head_unwrap_guard(twice); + ASSERT_TRUE(head_probe_text_is(source, twice_head, "twice(x)")); + ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice)); + ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice_head)); + /* The second guard is a `when` operator too, and it is NOT on the head + * chain: a walk that treated any `when` above the head as a declaration + * would suppress the calls written inside it. */ + ASSERT_FALSE(cbm_elixir_def_head_is(twice, second_guard)); + + /* An operator definition's head is a binary_operator too, and unwrapping it + * would name the function after its own left parameter. Only the `when` + * token admits the peel, so `def a + b` is returned unchanged and its left + * operand is not a head. */ + TSNode op_def = probe.signature[4]; + ASSERT_STR_EQ("binary_operator", ts_node_type(op_def)); + ASSERT_FALSE(cbm_elixir_is_when_guard(op_def)); + ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(op_def), op_def)); + ASSERT_TRUE(cbm_elixir_def_head_is(op_def, op_def)); + TSNode op_lhs = ts_node_child_by_field_name(op_def, TS_FIELD("left")); + ASSERT_TRUE(head_probe_text_is(source, op_lhs, "a")); + ASSERT_FALSE(cbm_elixir_def_head_is(op_def, op_lhs)); + + ts_tree_delete(tree); + ts_parser_delete(parser); + PASS(); +} + +/* `def name(args) when guard` parses its whole head as a `when` + * binary_operator, so the name sits on the operator's left operand rather than + * directly under the call. The def extractor accepted only `call` and + * `identifier` there, so every guarded clause was dropped -- and a function + * whose clauses ALL carry guards never reached the graph at all, silently. + * Guards are ordinary Elixir: on one real 2.9k-function codebase this hid 216 + * of 270 all-guarded functions, and every call edge touching them. */ +TEST(extract_elixir_guarded_def_head) { + CBMFileResult *r = extract("defmodule Guarded do\n" + " def plain(x), do: x\n" + " def guarded(x) when is_binary(x), do: x\n" + " def guarded(x) when is_list(x), do: x\n" + " def multi(x, y) when is_binary(x) and is_integer(y), do: {x, y}\n" + " defp priv_guarded(x) when is_atom(x), do: x\n" + " def bare when true, do: :ok\n" + " def a + b, do: {a, b}\n" + " def c - d when is_integer(c), do: {c, d}\n" + " def multi_guard(x) when is_atom(x) when is_binary(x), do: x\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guarded.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "plain")); + ASSERT(has_def(r, "Function", "guarded")); + ASSERT(has_def(r, "Function", "multi")); + ASSERT(has_def(r, "Function", "priv_guarded")); + ASSERT(has_def(r, "Function", "bare")); + + /* Elixir admits more than one guard on a clause. tree-sitter-elixir nests + * those `when` operators right-associatively, so the declared head stays + * the outermost operator's left operand -- one node either way, and the + * peel must not stop short of it or return the guard. */ + ASSERT(has_def(r, "Function", "multi_guard")); + ASSERT_EQ(count_defs_named(r, "Function", "multi_guard"), 1); + + /* An operator definition is a binary_operator head too, but it is not a + * guard: unwrapping it names the function after its own LEFT PARAMETER. + * `def a + b` must not mint a function called `a`, nor `def c - d when ...` + * one called `c` -- the guard's left operand is itself the `c - d` + * operator, which names no function either. Neither operator form is + * extracted at all, which is what this grammar did before guards were + * handled; only the `when` token admits the unwrap. */ + ASSERT_EQ(count_defs_named(r, "Function", "a"), 0); + ASSERT_EQ(count_defs_named(r, "Function", "c"), 0); + cbm_free_result(r); + PASS(); +} + +/* A guard hides the head from the scope walk and from the calls walk. + * compute_elixir_func_qn accepts only a `call` or an `identifier` as a def's + * first argument, so a guarded clause resolved no QN and opened NO function + * scope: every call in its body was attributed to the FILE node, the function + * reported no outgoing edges, and its callees gained an in-edge from the file + * instead of from their caller. A paren-less guarded clause (`def f when g`) is + * worse still: it has no inner call node at all, so the guard operator itself + * falls through to extract_callee_name's first-identifier last resort and mints + * a phantom CALLS edge naming the function being defined. */ +TEST(extract_elixir_guarded_def_head_scope) { + CBMFileResult *r = extract("defmodule Guards do\n" + " def target(x), do: x\n" + " def other(x), do: x\n" + " def unguarded_caller(a), do: target(a)\n" + " def guarded_caller(a) when is_binary(a) do\n" + " target(a)\n" + " other(a)\n" + " end\n" + " def bare_guarded when true, do: target(:ok)\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guard_scope.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* A paren-less guarded head is a definition, never a call to itself. */ + ASSERT_EQ(count_calls_named(r, "bare_guarded"), 0); + + /* Both calls in the guarded body source to the guarded function, and the + * paren-less clause's body call to that clause. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.guarded_caller"), 1); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope.guarded_caller"), 1); + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.bare_guarded"), 1); + + /* Control on the path the fix does not touch: an unguarded clause still + * sources its body call to itself. This is NOT a leak detector -- the walk + * expires scopes by tree depth (pop_expired_scopes), so a sibling clause + * always unwinds the previous one before pushing its own and "pushed but + * never popped" is not a state this walk can reach. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.unguarded_caller"), 1); + + /* Nothing falls back to the file node. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope"), 0); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope"), 0); + + cbm_free_result(r); + PASS(); +} + +/* The half the calls and scope assertions above cannot see: the usages walk + * asks whether an identifier sits inside the def's SIGNATURE, and treats + * everything that does as part of the binding rather than as a reference. A + * guard makes that signature the whole `when` operator, which spans the guard + * expression too -- so `a` read by `is_binary(a)` was classified as its own + * binding and emitted no USAGE row at all. A guard is an expression, not a + * pattern: the parameters are bound by `guarded(a, b)` and every mention of one + * to its right is a read, exactly as the same mention in the body already is. + * Unwrapping the signature to the head restores those rows without admitting + * the binding sites themselves, which is why each name is asserted both within + * its scope and in total. */ +TEST(extract_elixir_guarded_def_head_guard_usages) { + CBMFileResult *r = extract("defmodule GuardUsage do\n" + " def plain(x), do: x\n" + " def guarded(a, b) when is_binary(a) and byte_size(a) > 3 do\n" + " b\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guard_usage.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* Both guard reads of the parameter are usages of the guarded clause. */ + ASSERT_EQ(count_usages_from(r, "a", "t.guard_usage.guarded"), 2); + /* ...and nothing else claims `a`: the `a` in `guarded(a, b)` binds it. */ + ASSERT_EQ(count_usages_named(r, "a"), 2); + + /* The body read is unchanged, and the head's `b` is still a binding -- + * an unwrap that took the guard instead of the head would add both head + * parameters here. */ + ASSERT_EQ(count_usages_from(r, "b", "t.guard_usage.guarded"), 1); + ASSERT_EQ(count_usages_named(r, "b"), 1); + + /* Control: an unguarded clause was never affected either way. */ + ASSERT_EQ(count_usages_from(r, "x", "t.guard_usage.plain"), 1); + ASSERT_EQ(count_usages_named(r, "x"), 1); + + cbm_free_result(r); + PASS(); +} + +/* Every clause of an Elixir function is its own `def` call, and an Elixir QN + * carries neither module nor arity, so all clauses compute one qualified name + * and collide on a single graph node. cbm_gbuf_upsert_node ranks a same-QN + * collision by LARGEST start_line, so the survivor was the last clause in the + * file and get_code_snippet returned that clause as the whole function: a + * two-clause `admin?` read back as `def admin?(%__MODULE__{}), do: false` -- + * the graph asserting a predicate that always returns false. The node has to + * span its own clauses, and ONLY its own: the fold is bounded to the + * immediately preceding definition and to the enclosing module body, so a def + * of another name ends the group and two same-named functions in two modules + * stay two nodes. */ +TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module) { + CBMFileResult *r = extract("defmodule Roles do\n" /* 1 */ + " def admin?(%{role: :admin}), do: true\n" /* 2 */ + " def admin?(%{}), do: false\n" /* 3 */ + "\n" /* 4 */ + " def promote(user), do: user\n" /* 5 */ + "\n" /* 6 */ + " def admin?(_other), do: false\n" /* 7 */ + "end\n" /* 8 */ + "\n" /* 9 */ + "defmodule Guests do\n" /* 10 */ + " def admin?(:root), do: true\n" /* 11 */ + " def admin?(_any), do: false\n" /* 12 */ + "end\n", /* 13 */ + CBM_LANG_ELIXIR, "t", "roles.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* The two adjacent Roles clauses fold; the clause past `promote` is its own + * group; the Guests pair is a DIFFERENT function and stays its own node. */ + ASSERT_EQ(3, count_defs_named(r, "Function", "admin?")); + ASSERT_EQ(1, count_defs_named(r, "Function", "promote")); + + const CBMDefinition *admin[3] = {NULL, NULL, NULL}; + const CBMDefinition *promote = NULL; + int seen = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") != 0) { + continue; + } + if (strcmp(r->defs.items[i].name, "admin?") == 0 && seen < 3) { + admin[seen++] = &r->defs.items[i]; + } else if (strcmp(r->defs.items[i].name, "promote") == 0) { + promote = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(admin[0]); + ASSERT_NOT_NULL(admin[1]); + ASSERT_NOT_NULL(admin[2]); + ASSERT_NOT_NULL(promote); + + ASSERT_EQ(2, (int)admin[0]->start_line); + ASSERT_EQ(3, (int)admin[0]->end_line); + /* separated from lines 2-3 by `promote`, so it keeps its own span */ + ASSERT_EQ(7, (int)admin[1]->start_line); + ASSERT_EQ(7, (int)admin[1]->end_line); + /* Guests.admin? never reaches back over the module boundary */ + ASSERT_EQ(11, (int)admin[2]->start_line); + ASSERT_EQ(12, (int)admin[2]->end_line); + ASSERT_EQ(5, (int)promote->start_line); + ASSERT_EQ(5, (int)promote->end_line); + cbm_free_result(r); + PASS(); +} + +/* A nested module is the one place where the "def of another name ends the + * group" rule is not enough: an Elixir QN carries no module, so `Outer.run` and + * `Outer.Inner.run` compute the SAME qualified name, and when Inner sits + * directly above Outer's own clause the two are adjacent in the extracted defs. + * The fold is bounded to the module body as well, so they stay two nodes. */ +TEST(extract_elixir_nested_module_clauses_do_not_fold) { + CBMFileResult *r = extract("defmodule Outer do\n" /* 1 */ + " defmodule Inner do\n" /* 2 */ + " def run(a), do: a\n" /* 3 */ + " end\n" /* 4 */ + "\n" /* 5 */ + " def run(b), do: b + 1\n" /* 6 */ + "end\n", /* 7 */ + CBM_LANG_ELIXIR, "t", "nested.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(2, count_defs_named(r, "Function", "run")); + + const CBMDefinition *runs[2] = {NULL, NULL}; + int seen = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") == 0 && + strcmp(r->defs.items[i].name, "run") == 0 && seen < 2) { + runs[seen++] = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(runs[0]); + ASSERT_NOT_NULL(runs[1]); + ASSERT_EQ(3, (int)runs[0]->start_line); + ASSERT_EQ(3, (int)runs[0]->end_line); + ASSERT_EQ(6, (int)runs[1]->start_line); + ASSERT_EQ(6, (int)runs[1]->end_line); + cbm_free_result(r); + PASS(); +} + +/* Folding clauses has to decide is_exported for the merged node. A `def` clause + * and a `defp` clause of the same name AND arity are clauses of one function -- + * a private guard clause or a nil short-circuit written above the public head + * is ordinary Elixir -- so is_exported is the OR over the folded clauses: the + * name is callable from outside if any clause exports it, and a wholly private + * function stays unexported. */ +TEST(extract_elixir_folded_clause_is_exported_is_the_or) { + CBMFileResult *r = extract("defmodule Acc do\n" /* 1 */ + " def build(l), do: build(l)\n" /* 2 */ + " defp build([]), do: []\n" /* 3 */ + "\n" /* 4 */ + " defp trim(nil), do: nil\n" /* 5 */ + " defp trim(s), do: s\n" /* 6 */ + "end\n", /* 7 */ + CBM_LANG_ELIXIR, "t", "acc.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_defs_named(r, "Function", "build")); + ASSERT_EQ(1, count_defs_named(r, "Function", "trim")); + + const CBMDefinition *build = NULL; + const CBMDefinition *trim = NULL; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") != 0) { + continue; + } + if (strcmp(r->defs.items[i].name, "build") == 0) { + build = &r->defs.items[i]; + } else if (strcmp(r->defs.items[i].name, "trim") == 0) { + trim = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(build); + ASSERT_NOT_NULL(trim); + ASSERT_EQ(2, (int)build->start_line); + ASSERT_EQ(3, (int)build->end_line); + ASSERT_TRUE(build->is_exported); + ASSERT_EQ(5, (int)trim->start_line); + ASSERT_EQ(6, (int)trim->end_line); + ASSERT_FALSE(trim->is_exported); + cbm_free_result(r); + + /* The other direction, and the only one the `prev->is_exported ||` disjunct + * decides. Above, the exporting clause is the FIRST one, so a plain + * `prev->is_exported = is_exported` assignment would also have to lose it + * on the later defp -- but a last-clause-wins assignment happens to be + * right whenever the def comes last. Here the defp comes first and the def + * second, so the surviving flag can only be true if the fold ORs rather + * than assigns. */ + CBMFileResult *o = extract("defmodule Ord do\n" /* 1 */ + " defp z(1), do: 1\n" /* 2 */ + " def z(2), do: 2\n" /* 3 */ + "end\n", /* 4 */ + CBM_LANG_ELIXIR, "t", "ord.ex"); + ASSERT_NOT_NULL(o); + ASSERT_FALSE(o->has_error); + ASSERT_EQ(1, count_defs_named(o, "Function", "z")); + const CBMDefinition *z = NULL; + for (int i = 0; i < o->defs.count; i++) { + if (strcmp(o->defs.items[i].label, "Function") == 0 && + strcmp(o->defs.items[i].name, "z") == 0) { + z = &o->defs.items[i]; + } + } + ASSERT_NOT_NULL(z); + ASSERT_EQ(2, (int)z->start_line); + ASSERT_EQ(3, (int)z->end_line); + ASSERT_TRUE(z->is_exported); + cbm_free_result(o); + PASS(); +} + +/* First definition with this name, for the Elixir clause-fold tests below. + * find_def() is declared much further down this file. */ +static const CBMDefinition *elixir_first_def(CBMFileResult *r, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0) { + return &r->defs.items[i]; + } + } + return NULL; +} + +/* A guarded clause head is a DECLARATION of the name, never a call to it, and + * the call extractor has to agree with the def extractor about which node the + * head is. It did not: it suppressed a head only when the head node IS the + * def's first named argument, but `def f(x) when g` parses its whole head as a + * `when` binary_operator, so the inner `f(x)` was not that argument and was + * recorded as a call to `f`. Under the folded span that phantom lands inside + * the function's own node, and self-recursion is decided by line containment, + * so an ordinary two-clause guarded function reported itself recursive. + * Recursion is a load-bearing signal: `recursive` is a queryable node property + * and seeds the cycle detection in pass_complexity. */ +TEST(extract_elixir_guarded_clause_head_is_not_a_self_call) { + CBMFileResult *r = extract("defmodule Guarded do\n" /* 1 */ + " def f(x) when is_integer(x), do: x\n" /* 2 */ + " def f(_x), do: 0\n" /* 3 */ + "\n" /* 4 */ + " def solo(x) when is_binary(x), do: x\n" /* 5 */ + "end\n", /* 6 */ + CBM_LANG_ELIXIR, "t", "guard_call.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* No call to either name exists anywhere in this file. */ + ASSERT_EQ(0, count_calls_named(r, "f")); + ASSERT_EQ(0, count_calls_named(r, "solo")); + /* The guard predicate IS a real call and stays one. */ + ASSERT_EQ(1, count_calls_named(r, "is_integer")); + ASSERT_EQ(1, count_calls_named(r, "is_binary")); + + ASSERT_EQ(1, count_defs_named(r, "Function", "f")); + const CBMDefinition *f = elixir_first_def(r, "f"); + ASSERT_NOT_NULL(f); + ASSERT_EQ(2, (int)f->start_line); + ASSERT_EQ(3, (int)f->end_line); + ASSERT_FALSE(f->is_recursive); + + /* A single guarded clause was wrong even before the span widened: the + * phantom landed on its own one-line node. */ + const CBMDefinition *solo = elixir_first_def(r, "solo"); + ASSERT_NOT_NULL(solo); + ASSERT_FALSE(solo->is_recursive); + cbm_free_result(r); + PASS(); +} + +/* Every reference of any kind this file records for `name`, across all four + * extractors the unified walk runs over one subtree: a call site, a value + * usage, a read/write occurrence, a type reference. Suppressing a node in ONE + * of them does not remove a phantom reference, it moves it -- handle_calls + * declining a node leaves state->callee_expr unset, and handle_usages then + * reaches the bare identifier and mints a USAGE instead. USAGE and CALLS are + * separate rows in the edge table, so the relabelled phantom also escapes the + * UNIQUE(source,target,type) dedup that had been collapsing it onto a real + * call, and pass_importance counts an inbound reference that did not exist + * before. An assertion naming only r->calls cannot see any of that. */ +static int elixir_any_ref_count(CBMFileResult *r, const char *name) { + int count = count_calls_named(r, name); + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, name) == 0) { + count++; + } + } + for (int i = 0; i < r->rw.count; i++) { + if (r->rw.items[i].var_name && strcmp(r->rw.items[i].var_name, name) == 0) { + count++; + } + } + for (int i = 0; i < r->type_refs.count; i++) { + if (r->type_refs.items[i].type_name && strcmp(r->type_refs.items[i].type_name, name) == 0) { + count++; + } + } + return count; +} + +/* The head suppression has to REMOVE the phantom, not relabel it. What makes + * that hold for a def head is not the calls walk alone: is_elixir_def_binding in + * extract_usages.c already treats everything inside a def's first argument as + * a binding occurrence rather than a reference, so the identifier handle_calls + * declines is declined by handle_usages too. That agreement is the invariant + * under test, and it is why the counts below are over every extractor rather + * than over r->calls. + * + * `spec_subject` pins the boundary between the two mechanisms. A typespec + * subject is the same shape as a def head, and suppressing it in the calls walk + * would only relabel its phantom as a USAGE, because nothing treats it as a + * binding. It is removed instead by skipping the whole `@spec` subtree before + * any extractor sees it (is_elixir_typespec_attribute, extract_unified.c), so + * the assertion is both that no reference of any kind survives AND that what + * does survive is never a relabelling: the equality holds at 0 == 0 here, and + * would hold at 1 == 1 on a build with the subtree skip removed but the calls + * suppression left in place. */ +TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind) { + CBMFileResult *r = extract("defmodule Heads do\n" /* 1 */ + " def guarded(x) when is_integer(x), do: x\n" /* 2 */ + " def guarded(_x), do: 0\n" /* 3 */ + "\n" /* 4 */ + " defp bare(x), do: x\n" /* 5 */ + "\n" /* 6 */ + " @spec spec_subject(integer) :: integer\n" /* 7 */ + " def spec_subject(n), do: n\n" /* 8 */ + "end\n", /* 9 */ + CBM_LANG_ELIXIR, "t", "heads.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* Nothing in this file references `guarded` or `bare`. Not as a call, not + * as a value, not as a read/write, not as a type. */ + ASSERT_EQ(0, elixir_any_ref_count(r, "guarded")); + ASSERT_EQ(0, elixir_any_ref_count(r, "bare")); + /* The guard predicate is a real call and survives as one. */ + ASSERT_EQ(1, count_calls_named(r, "is_integer")); + ASSERT_EQ(1, elixir_any_ref_count(r, "is_integer")); + /* Neither the @spec line nor the def head it precedes leaves a reference, + * and nothing has been relabelled into a usage, a read/write or a type + * reference. */ + ASSERT_EQ(0, count_calls_named(r, "spec_subject")); + ASSERT_EQ(count_calls_named(r, "spec_subject"), + elixir_any_ref_count(r, "spec_subject")); + + ASSERT_EQ(1, count_defs_named(r, "Function", "guarded")); + const CBMDefinition *guarded = elixir_first_def(r, "guarded"); + ASSERT_NOT_NULL(guarded); + ASSERT_EQ(2, (int)guarded->start_line); + ASSERT_EQ(3, (int)guarded->end_line); + ASSERT_FALSE(guarded->is_recursive); + cbm_free_result(r); + PASS(); +} + +/* The other direction: suppressing declaration heads must not blind the + * detector to real recursion, and folding must not lose a self-call written in + * a clause that is no longer the surviving one. `walk` recurses from its + * guarded second clause; exactly one call to `walk` is recorded (the body one, + * not the two heads) and the folded node is recursive. */ +TEST(extract_elixir_real_recursion_survives_head_suppression) { + CBMFileResult *r = extract("defmodule Rec do\n" /* 1 */ + " def walk([]), do: []\n" /* 2 */ + " def walk([h | t]) when is_integer(h), do: [h | walk(t)]\n" /* 3 */ + "end\n", /* 4 */ + CBM_LANG_ELIXIR, "t", "rec.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_calls_named(r, "walk")); + ASSERT_EQ(1, count_defs_named(r, "Function", "walk")); + const CBMDefinition *walk = elixir_first_def(r, "walk"); + ASSERT_NOT_NULL(walk); + ASSERT_EQ(2, (int)walk->start_line); + ASSERT_EQ(3, (int)walk->end_line); + ASSERT_TRUE(walk->is_recursive); + cbm_free_result(r); + PASS(); +} + +/* An Elixir module attribute parses as `unary_operator(@, call(, args))`, + * so a typespec line reaches the unified walk as ordinary code and every symbol + * it names collects a phantom inbound edge from the enclosing Module -- a CALLS + * onto the specified function, and for `@type` a USAGE plus a WRITES asserting + * a mutation that does not exist. On a codebase whose convention is @spec on + * every public function, fan_in then never reaches 0 and "which exported + * functions nothing calls" is unanswerable. + * + * All six heads in the skip list are exercised, so deleting any one entry from + * typespec_heads[] breaks this test. + * + * Two of the groups below pin a decision rather than a repair, and both are + * argued in is_elixir_typespec_attribute's comment: + * + * type_refs[] -- a typespec's type references are not indexed at all. A type + * declaration mints no node, so such a reference resolves onto nothing or + * onto a same-named FUNCTION, which is the pollution rather than a record of + * it. Restoring any of them must break this test. + * + * behaviour_contract_names[] -- accepted cost. A @callback / @macrocallback + * name is a function name, so before this change it resolved cross-file onto + * the functions implementing the behaviour. Nothing replaces that edge. + * + * The attribute NAME phantom outside the typespec family is deliberately still + * there: `@timeout` keeps its one reference, and its VALUE is ordinary + * compile-time code that still runs. `guarded` is pinned at 0 rather than 1 + * because the guarded def head no longer mints a phantom either -- the calls + * walk now recognises a `when`-wrapped head as a declaration. */ +TEST(extract_elixir_typespec_attribute_is_not_code) { + /* Names a typespec declares, plus the six attribute heads themselves. + * Every one carries a reference on the pristine build. */ + static const char *const declared[] = {"with_spec", "entry", "secret", "handle", + "spec", "type", "typep", "opaque", + "callback", "macrocallback", NULL}; + /* Accepted cost: these used to resolve onto the behaviour's implementors. */ + static const char *const behaviour_contract_names[] = {"handle_it", "expand_it", NULL}; + /* Pinned decision: type references are not indexed -- remote, bare-local + * and builtin alike. */ + static const char *const type_refs[] = {"MyApp.User.t", "String.t", "MyApp.Vault.key", + "Macro.t", "t", "key", + "term", "integer", "reference", + "atom", NULL}; + CBMFileResult *r = extract("defmodule Specced do\n" + " @timeout Application.compile_env(:app, :timeout)\n" + " @type entry :: String.t()\n" + " @typep secret :: MyApp.Vault.key()\n" + " @opaque handle :: reference()\n" + " @callback handle_it(term) :: :ok\n" + " @macrocallback expand_it(term) :: Macro.t()\n" + " @spec with_spec(MyApp.User.t()) :: integer\n" + " def with_spec(n), do: n\n" + " def without_spec(n), do: n\n" + " @spec guarded(atom) :: :ok\n" + " def guarded(x) when is_atom(x), do: :ok\n" + "end\n", + CBM_LANG_ELIXIR, "t", "specced.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* The definitions themselves are a separate pass and are unaffected. */ + ASSERT(has_def(r, "Function", "with_spec")); + ASSERT(has_def(r, "Function", "without_spec")); + ASSERT(has_def(r, "Function", "guarded")); + /* Control on the instrument, not a pin: `without_spec` carries no typespec + * and is never called, so it reads 0 on the pristine build too. It is here + * to show elixir_any_ref_count does not score a definition as a reference, + * which is what would make every 0 below vacuous. It cannot fail first and + * cannot catch a regression. */ + ASSERT_EQ(0, elixir_any_ref_count(r, "without_spec")); + for (const char *const *name = declared; *name; name++) { + ASSERT_EQ(0, elixir_any_ref_count(r, *name)); + } + for (const char *const *name = behaviour_contract_names; *name; name++) { + ASSERT_EQ(0, elixir_any_ref_count(r, *name)); + } + for (const char *const *name = type_refs; *name; name++) { + ASSERT_EQ(0, elixir_any_ref_count(r, *name)); + } + /* A non-typespec attribute is untouched: its value really is compile-time + * code, and its name still mints the one phantom this fix does not close. */ + ASSERT_EQ(1, count_calls_named(r, "Application.compile_env")); + ASSERT_EQ(1, elixir_any_ref_count(r, "timeout")); + /* Both phantoms onto `guarded` are gone: the @spec one to the subtree skip, + * the def-head one to the calls walk recognising a guarded head. */ + ASSERT_EQ(0, elixir_any_ref_count(r, "guarded")); + cbm_free_result(r); + PASS(); +} + /* tree-sitter-elixir gives a call's arguments node no field name, so the * generic `arguments` field lookup returns null and first_string_arg was never * populated for any Elixir call — Phoenix route paths, service URLs and config @@ -13762,7 +14527,18 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); + RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it); + RUN_TEST(extract_elixir_guarded_def_head); + RUN_TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module); + RUN_TEST(extract_elixir_nested_module_clauses_do_not_fold); + RUN_TEST(extract_elixir_folded_clause_is_exported_is_the_or); + RUN_TEST(extract_elixir_guarded_clause_head_is_not_a_self_call); + RUN_TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind); + RUN_TEST(extract_elixir_real_recursion_survives_head_suppression); + RUN_TEST(extract_elixir_typespec_attribute_is_not_code); RUN_TEST(elixir_call_string_argument); + RUN_TEST(extract_elixir_guarded_def_head_scope); + RUN_TEST(extract_elixir_guarded_def_head_guard_usages); RUN_TEST(elixir_channels_pubsub_and_handle_in); RUN_TEST(haskell_function); RUN_TEST(haskell_infix_definition_name); diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index 742390ae6..faffd78e4 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -173,6 +173,13 @@ TEST(infrascan_service_pattern_match_rejects_ids_inside_words) { {"kafkaesque.story.tell", CBM_SVC_NONE}, /* "Axios" glued to a lowercase continuation */ {"geo.Axiosphere.map", CBM_SVC_NONE}, + /* Angular receiver rows end in a dot (#2768): a longer receiver that + * only starts with the same letters is not the HttpClient (NestJS + * route registration, AngularJS test double) */ + {"this.httpAdapter.get", CBM_SVC_NONE}, + {"this.httpServer.post", CBM_SVC_NONE}, + {"$httpBackend.expectGET", CBM_SVC_NONE}, + {"this.httpAdapter\n .get", CBM_SVC_NONE}, {NULL, CBM_SVC_NONE}, }; ASSERT_EQ(svc_case_mismatches(cases), 0); @@ -202,6 +209,18 @@ TEST(infrascan_service_pattern_match_keeps_real_library_qns) { {"Axios.post", CBM_SVC_HTTP}, {"AxiosInstance.get", CBM_SVC_HTTP}, {"curl_exec", CBM_SVC_HTTP}, + /* Angular HttpClient receivers (#2768); `_httpClient` is the common + * C# field spelling of the same client */ + {"this.http.get", CBM_SVC_HTTP}, + {"this.httpClient.post", CBM_SVC_HTTP}, + {"httpClient.put", CBM_SVC_HTTP}, + {"_httpClient.GetAsync", CBM_SVC_HTTP}, + {"$http.get", CBM_SVC_HTTP}, + /* a chain wrapped before the verb keeps the line break (LF / CRLF) */ + {"this.http\n .post", CBM_SVC_HTTP}, + {"this.http\r\n .delete", CBM_SVC_HTTP}, + {"this.httpClient\n .put", CBM_SVC_HTTP}, + {"$http\n .get", CBM_SVC_HTTP}, {"surf.get", CBM_SVC_HTTP}, {"hyper.Client.request", CBM_SVC_HTTP}, {"phin.promisified", CBM_SVC_HTTP}, diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 3e387111c..b82fb3098 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -10227,6 +10227,57 @@ TEST(tool_delete_project_mutation_guard_blocks_then_releases) { PASS(); } +/* delete_project removes the ADR sidecar with the project, and a re-index of a + * same-named project afterwards starts with no ADR (round-2 review item 7c). */ +TEST(tool_delete_project_removes_adr_sidecar) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-adr-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "adr-delete-project"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *setup = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(setup); + ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/adr-delete-project"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_store(setup, project, "# Decision\nkeep it"), CBM_STORE_OK); + cbm_store_close(setup); + + char adr_path[CBM_SZ_1K]; + snprintf(adr_path, sizeof(adr_path), "%s.adr.db", db_path); + ASSERT_TRUE(cbm_file_exists(adr_path)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"adr-delete-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "deleted")); + free(resp); + + ASSERT_FALSE(cbm_file_exists(db_path)); + ASSERT_FALSE(cbm_file_exists(adr_path)); /* sidecar removed with the project */ + + /* A fresh store of the same name starts with no ADR. */ + cbm_store_t *fresh = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(fresh); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(fresh, project, &adr), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&adr); + cbm_store_close(fresh); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cbm_rmdir(cache); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + PASS(); +} + TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) { char root[CBM_SZ_1K]; (void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir()); @@ -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 .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); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index b73946cf3..4b67ea200 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -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); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b47cf1bbf..9157c4bcd 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -13,6 +13,7 @@ #include "lsp/c_lsp.h" #include "lsp/py_lsp.h" #include "pipeline/lsp_surface.h" +#include "pipeline/worker_pool.h" #include "pipeline/pass_lsp_cross.h" #include "pipeline/pipeline_internal.h" #include "pipeline/helm_template.h" @@ -122,6 +123,269 @@ TEST(pipeline_create_free) { PASS(); } +TEST(pipeline_background_worker_policy_preserves_overrides) { + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + cbm_unsetenv("CBM_WORKERS"); + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + + cbm_pipeline_t *foreground = cbm_pipeline_new("/some/path", NULL, CBM_MODE_FULL); + cbm_pipeline_t *background = cbm_pipeline_new("/some/path", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(foreground); + ASSERT_NOT_NULL(background); + cbm_pipeline_set_background(background, true); + + int foreground_default = cbm_pipeline_worker_count(foreground); + int background_default = cbm_pipeline_worker_count(background); + bool defaults_match = foreground_default == cbm_default_worker_count(true) && + background_default == cbm_pipeline_background_worker_width( + cbm_default_worker_count(true), + cbm_default_worker_count(false)) && + foreground_default >= 1 && background_default >= 1 && + (foreground_default < 2 || background_default >= 2); + + /* A pinned single worker leaves no parallel route to preserve. */ + cbm_setenv("CBM_WORKERS", "1", 1); + bool env_override = + cbm_pipeline_worker_count(foreground) == 1 && cbm_pipeline_worker_count(background) == 1; + cbm_setenv("CBM_WORKERS", "3", 1); + env_override = env_override && cbm_pipeline_worker_count(foreground) == 3 && + cbm_pipeline_worker_count(background) == 3; + + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + bool recovery_override = cbm_pipeline_worker_count(foreground) == 1 && + cbm_pipeline_worker_count(background) == 1; + + cbm_pipeline_free(foreground); + cbm_pipeline_free(background); + saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS"); + saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1) + : cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + free(saved_workers); + free(saved_single); + + ASSERT_TRUE(defaults_match); + ASSERT_TRUE(env_override); + ASSERT_TRUE(recovery_override); + PASS(); +} + +/* An automatic refresh keeps CPU headroom but never falls to one worker, the + * sequential route, while the foreground width could run in parallel. Each + * host shape is given as the two widths cbm_default_worker_count() derives + * from it (foreground: every core; headroom: performance cores minus one, at + * least one), so the verdict does not depend on the machine running it. */ +TEST(pipeline_background_worker_width_keeps_parallel_route) { + /* One core: foreground 1, headroom 1. Nothing to preserve. */ + ASSERT_EQ(cbm_pipeline_background_worker_width(1, 1), 1); + /* Two performance cores: headroom alone would be 1. */ + ASSERT_EQ(cbm_pipeline_background_worker_width(2, 1), 2); + /* Two performance cores beside efficiency cores: same floor. */ + ASSERT_EQ(cbm_pipeline_background_worker_width(8, 1), 2); + /* Larger hosts keep their headroom unchanged. */ + ASSERT_EQ(cbm_pipeline_background_worker_width(3, 2), 2); + ASSERT_EQ(cbm_pipeline_background_worker_width(4, 3), 3); + ASSERT_EQ(cbm_pipeline_background_worker_width(18, 5), 5); + PASS(); +} + +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +enum { SURFACE_BUDGET_FILES = 5 }; + +typedef struct { + atomic_int expected; + atomic_int arrivals; + atomic_int live; + atomic_int peak; + atomic_bool released; + atomic_int visits[SURFACE_BUDGET_FILES]; + int helpers; +} surface_budget_probe_t; + +static void surface_budget_spawned(int count, int helpers, void *arg) { + surface_budget_probe_t *probe = arg; + probe->helpers = helpers; + int participants = helpers + 1; + atomic_store(&probe->expected, count < participants ? count : participants); +} + +static void surface_budget_row(int index, bool entering, void *arg) { + surface_budget_probe_t *probe = arg; + if (!entering) { + atomic_fetch_sub(&probe->live, 1); + return; + } + atomic_fetch_add(&probe->visits[index], 1); + int live = atomic_fetch_add(&probe->live, 1) + 1; + int peak = atomic_load(&probe->peak); + while (peak < live && !atomic_compare_exchange_weak(&probe->peak, &peak, live)) {} + atomic_fetch_add(&probe->arrivals, 1); + /* No timing window: hold the first wave until every possible participant + * has entered. The post-spawn observation accounts for partial thread + * creation and serial fallback, so neither can strand this gate. The test + * runner's watchdog bounds a broken dispatch without making time a verdict. */ + while (!atomic_load(&probe->released)) { + int expected = atomic_load(&probe->expected); + if (expected > 0 && atomic_load(&probe->arrivals) >= expected) { + atomic_store(&probe->released, true); + } + } +} + +TEST(pipeline_surface_respects_total_worker_budget) { + CBMFileResult results[SURFACE_BUDGET_FILES] = {0}; + CBMFileResult *cache[SURFACE_BUDGET_FILES]; + cbm_file_info_t files[SURFACE_BUDGET_FILES] = { + {.rel_path = "a.go", .language = CBM_LANG_GO}, + {.rel_path = "b.go", .language = CBM_LANG_GO}, + {.rel_path = "c.go", .language = CBM_LANG_GO}, + {.rel_path = "d.go", .language = CBM_LANG_GO}, + {.rel_path = "e.go", .language = CBM_LANG_GO}, + }; + surface_budget_probe_t probe = {0}; + for (int i = 0; i < SURFACE_BUDGET_FILES; i++) { + cache[i] = &results[i]; + atomic_init(&probe.visits[i], 0); + } + cache[1] = NULL; /* The skipped row must not change stable file ordering. */ + atomic_init(&probe.expected, 0); + atomic_init(&probe.arrivals, 0); + atomic_init(&probe.live, 0); + atomic_init(&probe.peak, 0); + atomic_init(&probe.released, false); + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + cbm_setenv("CBM_WORKERS", "2", 1); + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_pipeline_t *pipeline = cbm_pipeline_new("/some/path", NULL, CBM_MODE_FULL); + cbm_pipeline_set_background(pipeline, true); + cbm_pipeline_ctx_t ctx = {.pipeline = pipeline}; + cbm_lsp_surface_row_t *rows = NULL; + int count = 0; + cbm_parallel_test_set_spawn_observer(surface_budget_spawned, &probe); + cbm_lsp_surface_test_set_row_observer(surface_budget_row, &probe); + int rc = cbm_lsp_surface_build_rows(&ctx, "budget", cache, files, SURFACE_BUDGET_FILES, NULL, + NULL, &rows, &count); + int parallel_peak = atomic_load(&probe.peak); + int parallel_helpers = probe.helpers; + atomic_store(&probe.expected, 0); + atomic_store(&probe.arrivals, 0); + atomic_store(&probe.peak, 0); + atomic_store(&probe.released, false); + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + cbm_lsp_surface_row_t *serial_rows = NULL; + int serial_count = 0; + int serial_rc = cbm_lsp_surface_build_rows(&ctx, "budget", cache, files, SURFACE_BUDGET_FILES, + NULL, NULL, &serial_rows, &serial_count); + cbm_lsp_surface_test_set_row_observer(NULL, NULL); + cbm_parallel_test_set_spawn_observer(NULL, NULL); + bool same = rc == 0 && serial_rc == 0 && rows && serial_rows && + count == SURFACE_BUDGET_FILES - 1 && count == serial_count; + for (int i = 0; same && i < count; i++) { + same = strcmp(rows[i].rel_path, files[i < 1 ? i : i + 1].rel_path) == 0 && + strcmp(rows[i].defs_json, serial_rows[i].defs_json) == 0 && + strcmp(rows[i].surface_sha, serial_rows[i].surface_sha) == 0; + } + cbm_store_free_lsp_surfaces(rows, count); + cbm_store_free_lsp_surfaces(serial_rows, serial_count); + cbm_pipeline_free(pipeline); + saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS"); + saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1) + : cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + free(saved_workers); + free(saved_single); + + ASSERT_EQ(rc, 0); + ASSERT_EQ(serial_rc, 0); + ASSERT_TRUE(same); + ASSERT_GTE(parallel_helpers, 1); /* A serial setup is not a concurrency repro. */ + ASSERT_EQ(probe.helpers, 0); + ASSERT_EQ(atomic_load(&probe.live), 0); + for (int i = 0; i < SURFACE_BUDGET_FILES; i++) { + ASSERT_EQ(atomic_load(&probe.visits[i]), 2); + } + ASSERT_EQ(atomic_load(&probe.peak), 1); + ASSERT_EQ(parallel_peak, 2); + PASS(); +} +#endif + +TEST(pipeline_background_policy_preserves_index_results) { + ASSERT_EQ(setup_test_repo(), 0); + /* 64 fillers plus setup_test_repo's three files exceed MIN_FILES_FOR_PARALLEL + * (50). Below that threshold both policies take the sequential path, so the + * comparison stays green even with the background policy removed. */ + for (int i = 0; i < 64; i++) { + char name[32]; + char body[96]; + (void)snprintf(name, sizeof(name), "pad%02d.go", i); + (void)snprintf(body, sizeof(body), "package main\n\nfunc Pad%02d() int {\n\treturn %d\n}\n", + i, i); + if (th_write_file(TH_PATH(g_tmpdir, name), body) != 0) { + teardown_test_repo(); + FAIL("failed to pad fixture past MIN_FILES_FOR_PARALLEL"); + } + } + + /* An inherited CBM_WORKERS pin makes both runs use the same count. Drop it + * so this compares an all-core index against a headroom index. */ + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + if ((old_workers && !saved_workers) || (old_single && !saved_single)) { + free(saved_workers); + free(saved_single); + teardown_test_repo(); + FAIL("failed to save worker env"); + } + cbm_unsetenv("CBM_WORKERS"); + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + + char foreground_db[512]; + char background_db[512]; + (void)snprintf(foreground_db, sizeof(foreground_db), "%s/foreground.db", g_tmpdir); + (void)snprintf(background_db, sizeof(background_db), "%s/background.db", g_tmpdir); + + cbm_pipeline_t *foreground = cbm_pipeline_new(g_tmpdir, foreground_db, CBM_MODE_FULL); + cbm_pipeline_t *background = cbm_pipeline_new(g_tmpdir, background_db, CBM_MODE_FULL); + int foreground_rc = -1; + int background_rc = -1; + int foreground_nodes = -1; + int foreground_edges = -1; + int background_nodes = -1; + int background_edges = -1; + bool created = foreground && background; + if (created) { + cbm_pipeline_set_background(background, true); + foreground_rc = cbm_pipeline_run(foreground); + background_rc = cbm_pipeline_run(background); + cbm_pipeline_get_committed_counts(foreground, &foreground_nodes, &foreground_edges); + cbm_pipeline_get_committed_counts(background, &background_nodes, &background_edges); + } + + cbm_pipeline_free(foreground); + cbm_pipeline_free(background); + teardown_test_repo(); + saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS"); + saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1) + : cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + free(saved_workers); + free(saved_single); + + ASSERT_TRUE(created); + ASSERT_EQ(foreground_rc, 0); + ASSERT_EQ(background_rc, 0); + ASSERT_GT(foreground_nodes, 0); + ASSERT_EQ(background_nodes, foreground_nodes); + ASSERT_EQ(background_edges, foreground_edges); + PASS(); +} + TEST(pipeline_null_repo) { cbm_pipeline_t *p = cbm_pipeline_new(NULL, NULL, CBM_MODE_FULL); ASSERT_NULL(p); @@ -1088,6 +1352,217 @@ TEST(pipeline_records_unresolved_injected_call_sites) { PASS(); } +/* An ADR written from a test hook BETWEEN the start of a reindex and its + * publication survives — the write lands in the ".adr.db" sidecar, which the + * graph-DB rebuild never touches (round-2 review item 7a, full route). */ +typedef struct { + char db_path[512]; + char project[256]; + const char *adr; +} adr_hook_ctx_t; + +static void write_adr_before_final_manifest(void *userdata) { + adr_hook_ctx_t *c = (adr_hook_ctx_t *)userdata; + cbm_store_t *s = cbm_store_open_path_existing(c->db_path); + if (!s) { + s = cbm_store_open_path(c->db_path); + } + if (s) { + (void)cbm_store_adr_store(s, c->project, c->adr); + cbm_store_close(s); + } +} + +TEST(pipeline_adr_written_during_reindex_survives) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_hook_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + adr_hook_ctx_t ctx = {.adr = "# Decision\nWritten mid-reindex."}; + snprintf(ctx.db_path, sizeof(ctx.db_path), "%s", db_path); + snprintf(ctx.project, sizeof(ctx.project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Force a full reindex, writing the ADR from the publication hook. */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_before_final_manifest_once(write_adr_before_final_manifest, &ctx); + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + /* Pin the route so this stays a full-rebuild test if routing changes. */ + ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_FORCED_FULL); + cbm_pipeline_free(p2); + cbm_pipeline_incremental_test_reset_faults(); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, ctx.project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, ctx.adr); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + +/* Migrate-before-rebuild fails closed: if the sidecar cannot be opened (here a + * DIRECTORY sits at ".adr.db"), the reindex must NOT delete the old + * generation — it returns CBM_PIPELINE_ABORT_PRESERVE_DB and the prior graph is + * left intact. Deterministic, no hook. */ +TEST(pipeline_adr_migrate_failure_preserves_generation) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_migfail_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + cbm_store_t *before = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(before); + int nodes_before = cbm_store_count_nodes(before, project); + cbm_store_close(before); + ASSERT_TRUE(nodes_before > 0); + + /* A directory at ".adr.db" makes the sidecar open (and thus the + * migrate-before-delete step) fail deterministically. */ + char adr_dir[600]; + snprintf(adr_dir, sizeof(adr_dir), "%s.adr.db", db_path); + ASSERT_EQ(cbm_mkdir(adr_dir), 0); + + /* Force a full reindex; migrate-before-delete must fail closed. */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + int rc = cbm_pipeline_run(p2); + cbm_pipeline_free(p2); + ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); + + /* Old generation intact: the graph DB was not deleted or rebuilt. */ + ASSERT_TRUE(cbm_file_exists(db_path)); + cbm_store_t *after = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(after); + ASSERT_EQ(cbm_store_count_nodes(after, project), nodes_before); + cbm_store_close(after); + + rm_rf(tmp); + PASS(); +} + +/* Upgrade order: a legacy ADR row (pre-sidecar, in the graph DB) is migrated + * BEFORE a forced-full reindex deletes the old generation, with NO ADR read + * first — the pipeline's migrate-before-rebuild step must move it (item 7b). */ +TEST(pipeline_adr_legacy_migrates_before_forced_reindex) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_upgrade_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Seed the ADR directly into the graph DB's project_summaries (the legacy + * layout), bypassing the sidecar entirely. */ + static const char adr_text[] = "# Decision\nLegacy row, migrated before rebuild."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + sqlite3_exec(raw, + "CREATE TABLE IF NOT EXISTS project_summaries (project TEXT PRIMARY KEY," + " summary TEXT NOT NULL, source_hash TEXT NOT NULL, created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL);", + NULL, NULL, NULL); + char *sql = sqlite3_mprintf( + "INSERT OR REPLACE INTO project_summaries VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z'," + " '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* No ADR read here. Force a full reindex (which deletes the old graph DB). */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_pipeline_free(p2); + + /* The legacy ADR was migrated to the sidecar before the delete, so it is + * still readable. */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + TEST(pipeline_structure_edges) { if (setup_test_repo() != 0) { FAIL("failed to create temp dir"); @@ -6183,6 +6658,60 @@ TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full) { PASS(); } +/* Companion to pipeline_adr_written_during_reindex_survives (the full route): + * an ADR written from the publication hook mid-reindex must also survive the + * closure-DELTA route (item 7a — "both the full and the delta route"). The + * delta route clones/patches/renames the graph DB but never touches the + * ".adr.db" sidecar, so the hook-written ADR is preserved. A body-only edit + * forces the closure-repair (delta) route, which we assert explicitly so this + * can't silently pass on a full rebuild. */ +TEST(pipeline_adr_written_during_delta_reindex_survives) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_delta_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + closure_probe_repo(tmp); + char db[512]; + snprintf(db, sizeof(db), "%s/closure.db", tmp); + + cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(baseline); + ASSERT_EQ(cbm_pipeline_run(baseline), 0); + adr_hook_ctx_t ctx = {.adr = "# Decision\nWritten mid-delta-reindex."}; + snprintf(ctx.db_path, sizeof(ctx.db_path), "%s", db); + snprintf(ctx.project, sizeof(ctx.project), "%s", cbm_pipeline_project_name(baseline)); + cbm_pipeline_free(baseline); + + /* Body-only edit (same exported surface) → closure-repair delta route. */ + write_temp_file(tmp, "lib.ts", + "export function closureProbeHelper(x: number): string {\n" + " const doubled = x + x;\n" + " return String(doubled);\n" + "}\n"); + + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_before_final_manifest_once(write_adr_before_final_manifest, &ctx); + cbm_pipeline_t *incr = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(incr); + ASSERT_EQ(cbm_pipeline_run(incr), 0); + cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route(); + cbm_pipeline_free(incr); + cbm_pipeline_incremental_test_reset_faults(); + /* Must be the DELTA route, not a full rebuild — otherwise this proves nothing. */ + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, ctx.project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, ctx.adr); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + th_rmtree(tmp); + PASS(); +} + /* Removing a definition (no additions) keeps the closure route AND must drop * the dependent's stale CALL_REFERENCE. This is the assertion legacy partial * could never pass: its QN-keyed re-link resurrected yesterday's edge. */ @@ -7214,7 +7743,6 @@ TEST(pipeline_publication_never_uses_a_predictable_staging_path) { .cancelled = NULL, .manifest = NULL, .manifest_count = 0, - .adr_content = NULL, .coverage = NULL, .coverage_count = 0, }; @@ -8855,79 +9383,215 @@ TEST(pipeline_incremental_successful_publication_preserves_adr) { PASS(); } -/* A forced-full rebuild must never erase an ADR merely because the old - * generation could not be read completely. The capture is part of the - * publication transaction: failure preserves both graph and ADR. */ -TEST(pipeline_full_adr_capture_failure_preserves_previous_generation) { +/* An ADR written in the pre-sidecar layout (a row in the graph DB's + * project_summaries) is migrated into the ".adr.db" sidecar the first time + * it is read, and then survives a full reindex — so upgrading needs no reindex + * and loses no decision record. */ +TEST(pipeline_adr_migrates_from_legacy_graph_row) { char tmp[256]; - snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_adr_capture_XXXXXX"); - ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); - write_temp_file(tmp, "generation.py", "def BeforeAdrCapture():\n return 1\n"); + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_migrate_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } char db_path[512]; - snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); - cbm_pipeline_incremental_test_reset_faults(); - cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(baseline); - ASSERT_EQ(cbm_pipeline_run(baseline), 0); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); char project[256]; - snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline)); - cbm_pipeline_free(baseline); + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); - static const char adr_text[] = "# Decision\nADR capture is fail-closed."; - cbm_store_t *adr_store = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(adr_store); - ASSERT_EQ(cbm_store_adr_store(adr_store, project, adr_text), CBM_STORE_OK); - cbm_store_close(adr_store); + /* Seed the ADR directly into the graph DB's project_summaries — the layout + * before the sidecar existed — bypassing cbm_store_adr_store (which now + * writes the sidecar). */ + static const char adr_text[] = "# Decision\nLegacy row migrates to the sidecar."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); - write_temp_file(tmp, "generation.py", "def AfterAdrCapture():\n return 2\n"); - cbm_pipeline_incremental_test_fail_adr_capture_once(); - cbm_pipeline_t *faulted = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(faulted); - int faulted_rc = cbm_pipeline_run(faulted); - cbm_pipeline_free(faulted); + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + ASSERT_TRUE(!cbm_file_exists(adr_db)); /* no sidecar yet */ - int faulted_before = -1; - int faulted_after = -1; - observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture", - &faulted_before, &faulted_after); - cbm_store_t *preserved = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(preserved); - cbm_adr_t preserved_adr = {0}; - int preserved_adr_rc = cbm_store_adr_get(preserved, project, &preserved_adr); - bool preserved_adr_matches = preserved_adr_rc == CBM_STORE_OK && preserved_adr.content && - strcmp(preserved_adr.content, adr_text) == 0; - cbm_store_adr_free(&preserved_adr); - cbm_store_close(preserved); + /* A READ returns the legacy row from the graph DB and must NOT create the + * sidecar (a query-only cache must stay unwritten). */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + ASSERT_TRUE(!cbm_file_exists(adr_db)); /* read did not create the sidecar */ - cbm_pipeline_incremental_test_reset_faults(); - cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(retry); - int retry_rc = cbm_pipeline_run(retry); - cbm_pipeline_free(retry); - int retry_before = -1; - int retry_after = -1; - observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture", &retry_before, - &retry_after); - cbm_store_t *published = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(published); - cbm_adr_t published_adr = {0}; - int published_adr_rc = cbm_store_adr_get(published, project, &published_adr); - bool published_adr_matches = published_adr_rc == CBM_STORE_OK && published_adr.content && - strcmp(published_adr.content, adr_text) == 0; - cbm_store_adr_free(&published_adr); - cbm_store_close(published); - cbm_pipeline_incremental_test_reset_faults(); - th_rmtree(tmp); + /* Force a full reindex; routing migrates the legacy row into the sidecar + * before the old generation is deleted, so the ADR survives. */ + for (int i = 0; i < 4; i++) { + char extra[512]; + snprintf(extra, sizeof(extra), "%s/extra%d.py", tmp, i); + f = fopen(extra, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_pipeline_free(p2); - ASSERT_EQ(faulted_rc, CBM_PIPELINE_ABORT_PRESERVE_DB); - ASSERT_EQ(faulted_before, 1); - ASSERT_EQ(faulted_after, 0); - ASSERT_TRUE(preserved_adr_matches); - ASSERT_EQ(retry_rc, 0); - ASSERT_EQ(retry_before, 0); - ASSERT_EQ(retry_after, 1); - ASSERT_TRUE(published_adr_matches); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_adr_t adr2 = {0}; + ASSERT_EQ(cbm_store_adr_get(s2, project, &adr2), CBM_STORE_OK); + ASSERT_NOT_NULL(adr2.content); + ASSERT_STR_EQ(adr2.content, adr_text); + cbm_store_adr_free(&adr2); + cbm_store_close(s2); + + rm_rf(tmp); + PASS(); +} + +/* A sidecar that exists but was never marked `migrated` is half-created (a crash + * or a killed process between CREATE and the migration COMMIT). A read must fall + * back to the legacy graph row so a partial sidecar never hides an ADR. */ +TEST(pipeline_adr_half_created_sidecar_falls_back_to_legacy) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_halfcreated_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Legacy ADR still only in the graph DB (pre-sidecar layout). */ + static const char adr_text[] = "# Decision\nHalf-created sidecar must not hide me."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* Fabricate a HALF-CREATED sidecar: schema present, NO `migrated` marker. */ + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + sqlite3 *side = NULL; + ASSERT_EQ(sqlite3_open(adr_db, &side), SQLITE_OK); + ASSERT_EQ(sqlite3_exec(side, + "CREATE TABLE project_summaries (project TEXT PRIMARY KEY, summary TEXT " + "NOT NULL, source_hash TEXT NOT NULL, created_at TEXT NOT NULL, " + "updated_at TEXT NOT NULL);" + "CREATE TABLE adr_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);", + NULL, NULL, NULL), + SQLITE_OK); + sqlite3_close(side); + ASSERT_TRUE(cbm_file_exists(adr_db)); /* sidecar exists but is unmarked */ + + /* The read must fall back to the legacy graph row, not report NOT_FOUND. */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + +/* ADRs live in the ".adr.db" sidecar, separate from the graph DB, and + * removing that sidecar (what delete_project does) removes the ADR store. */ +TEST(pipeline_adr_sidecar_is_separate_and_removable) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_sidecar_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + static const char adr_text[] = "# Decision\nADRs live in the sidecar."; + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_adr_store(s, project, adr_text), CBM_STORE_OK); + cbm_store_close(s); + + /* The ADR is in the sidecar, NOT in the graph DB's project_summaries. */ + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + ASSERT_TRUE(cbm_file_exists(adr_db)); + + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + sqlite3_stmt *st = NULL; + ASSERT_EQ(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM project_summaries;", -1, &st, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(st), SQLITE_ROW); + int graph_rows = sqlite3_column_int(st, 0); + sqlite3_finalize(st); + sqlite3_close(raw); + ASSERT_EQ(graph_rows, 0); + + /* Removing the sidecar (as delete_project does) removes the ADR store. */ + ASSERT_EQ(cbm_unlink(adr_db), 0); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_adr_t adr = {0}; + int rc = cbm_store_adr_get(s2, project, &adr); + cbm_store_adr_free(&adr); + cbm_store_close(s2); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + rm_rf(tmp); PASS(); } @@ -8944,8 +9608,8 @@ TEST(pipeline_semantic_manifest_rejects_non_directory_root) { cbm_file_hash_t *manifest = NULL; int manifest_count = -1; - int rc = cbm_pipeline_build_semantic_manifest("manifest-fail-closed", root_path, NULL, 0, NULL, - 0, NULL, NULL, &manifest, &manifest_count); + int rc = cbm_pipeline_build_semantic_manifest(NULL, "manifest-fail-closed", root_path, NULL, 0, + NULL, 0, NULL, NULL, &manifest, &manifest_count); cbm_pipeline_free_semantic_manifest(manifest, manifest_count > 0 ? manifest_count : 0); th_rmtree(tmp); @@ -8973,7 +9637,7 @@ TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory) { cbm_file_hash_t *manifest = NULL; int manifest_count = -1; - int rc = cbm_pipeline_build_semantic_manifest("manifest-skip-locked", tmp, NULL, 0, NULL, 0, + int rc = cbm_pipeline_build_semantic_manifest(NULL, "manifest-skip-locked", tmp, NULL, 0, NULL, 0, NULL, NULL, &manifest, &manifest_count); bool found_sibling_gitignore = false; @@ -11071,6 +11735,416 @@ TEST(pipeline_go_init_lsp_edges_per_file_parallel) { PASS(); } +/* Fixture for the bare-`init` probes (sequential and parallel twins), the + * shapes the kubernetes census found after #1915: a local closure `init := + * func(ctx)` that is called, written and passed by value (the e2e storage + * suites), a parameter named init (`func sum(init func() ref.Val)`), and a + * `package init` directory. The decoys are a package init in the caller's + * own package (what same_module bound before #1915) and an unrelated + * Watcher.init method (what the name fallback binds after it). Watcher.Start + * and Boot call that method through a selector, the control that must keep + * resolving. pad_files > 0 adds filler Go files past MIN_FILES_FOR_PARALLEL. */ +static void write_go_bare_init_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fxbareinit\n\ngo 1.22\n"); + write_temp_file(tmp, "suites/suite.go", + "package suites\n" + "\n" + "import \"context\"\n" + "\n" + "var registered []string\n" + "\n" + "func init() { registered = append(registered, \"volumes\") }\n" + "\n" + "func wrap(fn func(context.Context)) { _ = fn }\n" + "\n" + "func DefineTests(ctx context.Context) {\n" + "\tinit := func(ctx context.Context) {\n" + "\t\t_ = ctx\n" + "\t}\n" + "\tinit(ctx)\n" + "\twrap(init)\n" + "}\n" + "\n" + "func Sum(init func() int) int {\n" + "\treturn init()\n" + "}\n"); + write_temp_file(tmp, "watcher/watcher.go", + "package watcher\n" + "\n" + "type Watcher struct{ ready bool }\n" + "\n" + "func NewWatcher() *Watcher { return &Watcher{} }\n" + "\n" + "func (w *Watcher) init() { w.ready = true }\n" + "\n" + "func (w *Watcher) Start() {\n" + "\tw.init()\n" + "}\n" + "\n" + "func Boot() {\n" + "\tw := NewWatcher()\n" + "\tw.init()\n" + "}\n"); + write_temp_file(tmp, "boot/init/init.go", + "// Package init installs defaults for its importers.\n" + "package init\n" + "\n" + "func init() { setup() }\n" + "\n" + "func setup() {}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +static bool bare_init_qn_ends_with(const char *qn, const char *suffix) { + size_t ql = strlen(qn); + size_t sl = strlen(suffix); + return ql >= sl && strcmp(qn + ql - sl, suffix) == 0; +} + +/* Resolver edges into any node named `init` whose source is not one of the + * two selector controls. Every such edge in the fixture binds a bare `init`, + * which in Go is always a local name. */ +static int go_bare_init_false_edges(cbm_store_t *s, const char *project) { + static const char *const types[] = {"CALLS", "USAGE", "WRITES", "READS", "CALL_REFERENCE"}; + int hits = 0; + for (size_t t = 0; t < sizeof(types) / sizeof(types[0]); t++) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, types[t], &edges, &edge_count); + for (int i = 0; i < edge_count; i++) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK && + cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK && + src.qualified_name && tgt.name && strcmp(tgt.name, "init") == 0 && + !bare_init_qn_ends_with(src.qualified_name, ".watcher.Watcher.Start") && + !bare_init_qn_ends_with(src.qualified_name, ".watcher.Boot")) { + printf(" bare init bound: %s %s -> %s\n", types[t], src.qualified_name, + tgt.qualified_name ? tgt.qualified_name : "?"); + hits++; + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + } + return hits; +} + +static int assert_go_bare_init_edges(cbm_store_t *s, const char *project) { + /* The decoys exist, so a zero below is a refused binding and not an + * absent target: two package inits and the Watcher method. */ + cbm_node_t *inits = NULL; + int ic = 0; + cbm_store_find_nodes_by_name(s, project, "init", &inits, &ic); + int functions = 0; + int methods = 0; + for (int i = 0; i < ic; i++) { + functions += inits[i].label && strcmp(inits[i].label, "Function") == 0; + methods += inits[i].label && strcmp(inits[i].label, "Method") == 0; + } + cbm_store_free_nodes(inits, ic); + ASSERT_EQ(functions, 2); + ASSERT_EQ(methods, 1); + /* NEGATIVE: no bare `init` (closure call, closure write, closure passed + * by value, parameter call, `package init`) binds a package init or the + * unrelated method. */ + ASSERT_EQ(go_bare_init_false_edges(s, project), 0); + /* CONTROL: a selector call `w.init()` is not bare and still resolves to + * the method, from a method body and from a free function. */ + ASSERT_GTE( + go_calls_between(s, project, ".watcher.Watcher.Start", ".watcher.Watcher.init", NULL), 1); + ASSERT_GTE(go_calls_between(s, project, ".watcher.Boot", ".watcher.Watcher.init", NULL), 1); + return 0; +} + +/* R45 (follow-up to #1915): a bare Go `init` never binds a package init or, + * by name alone, an init method (cbm_go_suppress_bare_init_bind). */ +TEST(pipeline_go_bare_init_binds_nothing) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_bare_init_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_init_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_bare_init.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(assert_go_bare_init_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Parallel twin: >= MIN_FILES_FOR_PARALLEL (50) files plus CBM_WORKERS=4 + * take the parallel resolver, which carries its own copy of each guard. */ +TEST(pipeline_go_bare_init_binds_nothing_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_bare_init_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_init_fixture(tmp, 52); + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_bare_init_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(assert_go_bare_init_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_go_parallel_field_hint_requires_owner_segment) { + /* #1927: try_field_type_hint (parallel resolver only) matched the hinted + * type name as a raw SUBSTRING of the candidate QN. A single-letter + * receiver — `f.Close()` on an *os.File — hints "F", and "F" is a + * substring of "…FileStore.Close", so a stdlib call was rebound to an + * arbitrary project method at PP_FIELD_HINT_CONF and presented as + * field_type_hint (the one weak strategy the Go selector guard trusts). + * The hint must fire only when the candidate's OWNING segment — the + * dot-segment immediately before the method — EQUALS the hinted type; + * otherwise the resolution stays a weak short-name match and the Go + * guard (#1906) drops it. >= 50 files forces pass_parallel.c, the only + * path that runs try_field_type_hint(). */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_fth_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "go.mod", "module example.com/fxhint\n\ngo 1.22\n"); + /* Two project methods named Close → the registry resolves f.Close() as an + * ambiguous short-name match (candidate_count 2), the shape the hint + * upgrades. FileStore's QN carries the capital F the buggy substring + * latches onto; Conn is the second candidate. */ + write_temp_file(tmp, "filestore/filestore.go", + "package filestore\n" + "\n" + "type FileStore struct{ open bool }\n" + "\n" + "func (fs *FileStore) Close() {\n" + "\tfs.open = false\n" + "}\n"); + write_temp_file(tmp, "conn/conn.go", + "package conn\n" + "\n" + "type Conn struct{ live bool }\n" + "\n" + "func (cn *Conn) Close() {\n" + "\tcn.live = false\n" + "}\n"); + /* Two project methods named Find → same ambiguity, but here the receiver + * variable IS named after its type (`finder`), the case the hint exists + * for: the owning segment of Finder.Find equals the hinted "Finder". */ + write_temp_file(tmp, "finder/finder.go", + "package finder\n" + "\n" + "type Finder struct{ n int }\n" + "\n" + "func NewFinder() *Finder { return &Finder{n: 1} }\n" + "\n" + "func (fi *Finder) Find(id int) int {\n" + "\treturn fi.n + id\n" + "}\n"); + write_temp_file(tmp, "locator/locator.go", + "package locator\n" + "\n" + "type Locator struct{ n int }\n" + "\n" + "func (lo *Locator) Find(id int) int {\n" + "\treturn lo.n - id\n" + "}\n"); + write_temp_file(tmp, "app/app.go", + "package app\n" + "\n" + "import (\n" + "\t\"os\"\n" + "\n" + "\txf \"example.com/fxhint/finder\"\n" + ")\n" + "\n" + "func Lookup(path string) int64 {\n" + "\tf, err := os.Open(path)\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\tdefer f.Close()\n" + "\tst, err := f.Stat()\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\treturn st.Size()\n" + "}\n" + "\n" + "func UseFinder(id int) int {\n" + "\tfinder := xf.NewFinder()\n" + "\treturn finder.Find(id)\n" + "}\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_fth.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* (1) Reproduce-first: RED before the fix. The substring hint rebinds the + * stdlib f.Close() to FileStore.Close ("F" ⊂ QN) at high confidence and + * the Go guard keeps it. With segment equality the upgrade is refused, + * the match stays suffix_match, and the Go guard drops it. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "Lookup", "Close")); + /* (2) The intended hint case survives: `finder` names its type, so the + * owning segment of Finder.Find equals the hint. */ + ASSERT_TRUE(cross_file_call_exists(s, project, "UseFinder", "Find")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +static int count_calls_with(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, const char *fragment); + +TEST(pipeline_csharp_parallel_field_hint_requires_owner_segment) { + /* #1927, non-Go twin. Go no longer runs try_field_type_hint (e554a2ab), + * but every other language on the parallel path still does, and C# is the + * hint's intended consumer (it strips the _ / m_ field prefixes). C# + * method names are PascalCase, so a one-letter receiver hints a letter + * that every candidate QN already contains in the METHOD name: `w.Write()` + * hints "W", and a raw substring test accepts "...FileStore.Write" and + * "...Conn.Write" alike, whichever the registry lists first. The hint must + * bind only a candidate whose OWNING segment equals the hinted type. + * >= 50 files forces pass_parallel.c, the only path with the hint. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_cs_fth_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* Two project methods named Write: an untyped receiver resolves as an + * ambiguous short-name match (candidate_count 2), the shape the hint + * upgrades. */ + write_temp_file(tmp, "Storage/FileStore.cs", + "namespace Storage {\n" + " public class FileStore {\n" + " private int written;\n" + " public void Write(int v) { written += v; }\n" + " public void Put(int v) { written = v; }\n" + " }\n" + "}\n"); + write_temp_file(tmp, "Net/Conn.cs", + "namespace Net {\n" + " public class Conn {\n" + " private int sent;\n" + " public void Write(int v) { sent += v; }\n" + " }\n" + "}\n"); + /* Two project methods named Put, one owned by Store: the receiver `store` + * names its type, the case the hint exists for. FileStore's QN also + * contains "Store", so only segment equality picks Store.Put whatever + * the candidate order. */ + write_temp_file(tmp, "Repo/Store.cs", + "namespace Repo {\n" + " public class Store {\n" + " private int held;\n" + " public void Put(int v) { held = v; }\n" + " }\n" + "}\n"); + write_temp_file(tmp, "App/Worker.cs", + "namespace App {\n" + " public class Worker {\n" + " public void Log(System.IO.StreamWriter w) {\n" + " w.Write(1);\n" + " }\n" + " public void Keep() {\n" + " var store = Services.Resolve();\n" + " store.Put(2);\n" + " }\n" + " }\n" + "}\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[192]; + snprintf(name, sizeof(name), "Pad/Filler%d.cs", i); + snprintf(body, sizeof(body), + "namespace Pad {\n public class Filler%d {\n" + " public int Get%d() { return %d; }\n }\n}\n", + i, i, i); + write_temp_file(tmp, name, body); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/cs_fth.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* (1) Reproduce-first: RED before the fix. "W" is a substring of every + * Write candidate, so the old hint upgraded the StreamWriter call to an + * arbitrary project Write at PP_FIELD_HINT_CONF. No owner segment is "W", + * so the fixed hint must not fire at all. */ + ASSERT_FALSE(cross_file_call_has_strategy(s, project, "Log", "Write", "field_type_hint")); + /* (2) The intended hint case binds the owner it names, never FileStore. */ + ASSERT_EQ(count_calls_with(s, project, ".Keep", ".Store.Put", "field_type_hint"), 1); + ASSERT_EQ(count_calls_with(s, project, ".Keep", ".FileStore.Put", NULL), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Fixture for the #1928 cross-language reference-guard probes (sequential and * parallel twins). pad_files > 0 adds filler files to push the index over the * parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per @@ -15378,6 +16452,203 @@ TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential) { return typed_http_no_url_twin_case(0); } +/* Issue #2768: Angular HttpClient calls (this.http.get, this.httpClient.post, + * this.http.delete) emit HTTP_CALLS edges typed with the method verb, and are + * never misclassified as route registrations. */ +static int angular_http_client_typed_case(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_angular_http_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/item.service.ts", + "import { Injectable } from '@angular/core';\n" + "import { HttpClient } from '@angular/common/http';\n" + "\n" + "@Injectable({ providedIn: 'root' })\n" + "export class ItemService {\n" + " constructor(private http: HttpClient) {}\n" + "\n" + " getItems() {\n" + " return this.http.get('/items/list');\n" + " }\n" + "\n" + " getItem(id: string) {\n" + " return this.http.get(`/items/${id}`);\n" + " }\n" + "\n" + " createItem(item: any) {\n" + " return this.http.post('/items', item);\n" + " }\n" + "\n" + " deleteItem(id: string) {\n" + " return this.http.delete(`/items/${id}`);\n" + " }\n" + "\n" + " updateItem(id: string, item: any) {\n" + " return this.http\n" + " .put(`/items/${id}`, item);\n" + " }\n" + "}\n"); + + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/angular_http.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* Assert exact HTTP_CALLS with proper methods */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__GET__/items/list"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__GET__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__POST__/items"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__DELETE__/items/{}"), 1); + /* A chain wrapped before the verb (prettier style) is the same client call */ + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__PUT__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__ANY__"), 0); + + /* Assert no ANY twins */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__ANY__"), 0); + + /* Angular service must NOT emit HANDLES edges (not a server route registration) */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_angular_http_client_typed_parallel) { + return angular_http_client_typed_case(52); +} + +TEST(pipeline_angular_http_client_typed_sequential) { + return angular_http_client_typed_case(0); +} + +/* #2768: the arg-URL heuristic (detect_url_in_args, via=arg_url) covers calls + * no service pattern knows, and it now types the edge with the verb the callee + * names instead of minting a method-less __route__ANY__ route. Two wrapper + * shapes reach it on the parallel resolver: + * - a project wrapper method whose verb is not a route-registration suffix + * (`this.api.head('/items/health')`, `.options(...)`), resolved to the + * project method, so the URL may be the first argument; + * - a route-verb wrapper (`.get`) whose URL is not route shaped for the + * registration reader: here a keyword it does not read as a path + * (`self.session.get(resource="/items/list")`). + * Parallel only: the sequential resolver has no arg-URL heuristic (#2848). */ +TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_arg_url_verb_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "src/items.api.ts", + "export class ItemsApi {\n" + " head(path: string): Promise {\n" + " return fetch(path, { method: 'HEAD' });\n" + " }\n" + " options(path: string): Promise {\n" + " return fetch(path, { method: 'OPTIONS' });\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/item.service.ts", + "import { ItemsApi } from './items.api';\n" + "export class ItemService {\n" + " constructor(private readonly api: ItemsApi) {}\n" + " pingItems() {\n" + " return this.api.head('/items/health');\n" + " }\n" + " probeItems() {\n" + " return this.api.options('/items/list');\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/reader.py", + "class ItemsSession:\n" + " def get(self, *args, **kwargs):\n" + " return args\n" + "\n" + "class Reader:\n" + " def __init__(self):\n" + " self.session = ItemsSession()\n" + "\n" + " def read_items(self):\n" + " return self.session.get(resource=\"/items/list\")\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/arg_url_verb.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + static const struct { + const char *caller; + const char *typed_route; + } wrapped[] = {{"pingItems", "__route__HEAD__/items/health"}, + {"probeItems", "__route__OPTIONS__/items/list"}, + {"read_items", "__route__GET__/items/list"}}; + for (size_t i = 0; i < sizeof(wrapped) / sizeof(wrapped[0]); i++) { + /* The edge carries the callee's verb ... */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, wrapped[i].typed_route), 1); + /* ... and is the call site's only HTTP_CALLS edge: no ANY twin. */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__"), 1); + } + /* Not a route registration */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* #706 / #1147 end to end: a URL composed from module constants reaches the * HTTP classifier, so the client call forms an HTTP_CALLS edge. Python * `API_URL = API_BASE_URL + "/orders/create"` (env-derived base, literal path @@ -29731,6 +31002,12 @@ SUITE(pipeline) { RUN_TEST(pipeline_lock_release_allows_contender); /* Lifecycle */ RUN_TEST(pipeline_create_free); + RUN_TEST(pipeline_background_worker_policy_preserves_overrides); + RUN_TEST(pipeline_background_worker_width_keeps_parallel_route); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + RUN_TEST(pipeline_surface_respects_total_worker_budget); +#endif + RUN_TEST(pipeline_background_policy_preserves_index_results); RUN_TEST(pipeline_null_repo); RUN_TEST(pipeline_free_null); RUN_TEST(pipeline_cancel); @@ -29820,9 +31097,13 @@ SUITE(pipeline) { RUN_TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge); RUN_TEST(pipeline_go_multi_return_receiver_is_typed); RUN_TEST(pipeline_go_multi_return_receiver_is_typed_parallel); + RUN_TEST(pipeline_go_parallel_field_hint_requires_owner_segment); + RUN_TEST(pipeline_csharp_parallel_field_hint_requires_owner_segment); RUN_TEST(pipeline_go_multi_init_nodes_survive); RUN_TEST(pipeline_go_init_lsp_edges_per_file); RUN_TEST(pipeline_go_init_lsp_edges_per_file_parallel); + RUN_TEST(pipeline_go_bare_init_binds_nothing); + RUN_TEST(pipeline_go_bare_init_binds_nothing_parallel); RUN_TEST(pipeline_go_crossfile_type_alias_method); RUN_TEST(pipeline_go_crossfile_type_alias_method_parallel); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); @@ -29874,6 +31155,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential); + RUN_TEST(pipeline_angular_http_client_typed_parallel); + RUN_TEST(pipeline_angular_http_client_typed_sequential); + RUN_TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768); RUN_TEST(pipeline_url_constant_folding_http_calls_issue706_1147); RUN_TEST(pipeline_folded_topics_preserve_identity_sequential_parallel); RUN_TEST(pipeline_relative_folded_wrapper_url_candidates); @@ -30277,7 +31561,13 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_full_persist_failure_after_stage_dump_preserves_previous_generation); RUN_TEST(pipeline_incremental_persist_failure_preserves_previous_generation_and_retries); RUN_TEST(pipeline_incremental_successful_publication_preserves_adr); - RUN_TEST(pipeline_full_adr_capture_failure_preserves_previous_generation); + RUN_TEST(pipeline_adr_migrates_from_legacy_graph_row); + RUN_TEST(pipeline_adr_sidecar_is_separate_and_removable); + RUN_TEST(pipeline_adr_half_created_sidecar_falls_back_to_legacy); + RUN_TEST(pipeline_adr_written_during_reindex_survives); + RUN_TEST(pipeline_adr_written_during_delta_reindex_survives); + RUN_TEST(pipeline_adr_migrate_failure_preserves_generation); + RUN_TEST(pipeline_adr_legacy_migrates_before_forced_reindex); RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root); #ifndef _WIN32 RUN_TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory); diff --git a/tests/test_registry.c b/tests/test_registry.c index 9e99376ba..b711e32ed 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1260,6 +1260,45 @@ TEST(go_bare_ref_never_binds_field) { PASS(); } +TEST(go_bare_init_never_binds_init_namesake) { + /* Go cannot refer to a package init function, and a method or field + * named init needs a selector, so a bare `init` (a closure, a parameter, + * a `package init` clause) is always a local name. */ + /* Every Go Function named init is a package init: refused for every + * registry strategy, same_module included (the pre-#1915 binding). */ + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", NULL, "Function")); + /* A Method or Field namesake found by the name alone is refused. */ + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Method")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "unique_name", "Method")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Field")); + /* ... but not one that an import- or module-aware strategy produced. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Method")); + /* lsp_* answers are kept: the sequential resolver emits them before its + * registry guards run, so refusing one here would split the resolvers. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Method")); + /* The member half of a selector (`w.init()`, `e.init`) is not bare. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Field")); + /* Other names and other target kinds are untouched. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "initAll", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "Init", "suffix_match", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Variable")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Module")); + /* Go only: elsewhere a bare `init()` is an ordinary call (a Java or C++ + * method body calls its own init, a C or Python init is a function). */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "same_module", "Function")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, NULL, "suffix_match", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", NULL)); + PASS(); +} + TEST(c_bare_ref_never_binds_field) { /* #2666: a C struct member is named only as the member half of `a.b`, * `a->b` or a designator `.b` (field_identifier → is_member_access). A @@ -2656,6 +2695,7 @@ SUITE(registry) { RUN_TEST(registry_tie_prefers_csharp_implementation_over_ref_stub); RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); + RUN_TEST(go_bare_init_never_binds_init_namesake); RUN_TEST(c_bare_ref_never_binds_field); RUN_TEST(call_onto_field_follows_language_and_shape); RUN_TEST(c_bare_call_does_not_weigh_field_namesakes); diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 92af0994e..64a118288 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -620,6 +620,22 @@ TEST(adr_store_and_retrieve) { PASS(); } +TEST(adr_restore_from_graph_if_absent_memory_is_noop) { + /* A :memory: store keeps ADRs in the graph DB, so restore-if-absent is a + * well-formed no-op (returns OK) and leaves an existing ADR untouched. */ + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_store(s, "test", "v1"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_restore_from_graph_if_absent(s), CBM_STORE_OK); + cbm_adr_t adr; + ASSERT_EQ(cbm_store_adr_get(s, "test", &adr), CBM_STORE_OK); + ASSERT_STR_EQ(adr.content, "v1"); + cbm_store_adr_free(&adr); + cbm_store_close(s); + PASS(); +} + TEST(adr_upsert) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -2033,6 +2049,7 @@ SUITE(store_arch) { /* ADR */ RUN_TEST(adr_store_and_retrieve); RUN_TEST(adr_upsert); + RUN_TEST(adr_restore_from_graph_if_absent_memory_is_noop); RUN_TEST(adr_delete); RUN_TEST(adr_delete_not_found); RUN_TEST(doc_candidates_replace_and_get);