From ac46c17fc37f78ae518145e73d51b46ca3013fac Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sun, 30 Aug 2026 13:43:40 +0200 Subject: [PATCH 01/36] fix(pipeline): bind field_type_hint by owning QN segment, not substring try_field_type_hint accepted any candidate whose qualified name merely CONTAINED the hinted type name. A single-letter receiver (f.Close(), hint "F") matched almost every same-named candidate - the method name itself included - and rebound a call whose resolution had already failed to an arbitrary project symbol at confidence 0.85, presented as the one weak strategy the Go selector guard deliberately trusts. Require the candidate's owning dot-segment - the segment immediately before the method - to EQUAL the hinted type (or its I-prefixed interface form). The intended variable-named-after-its-type case keeps resolving (lrp_go_s8_field_type_hint stays green); the fabricated bindings lose their only anchor and fall back to the weak short-name strategies the guards already handle. Measured on a real ~1150-file Go repo (with the #1906/#1909/#1910 fixes applied): field_type_hint 1484 -> 356 CALLS edges, the single-character-receiver class 376 -> 0, Go->C/C++ targets 34 -> 0, and no other strategy absorbed the removed edges. 63 edges moved to the interface that declares the method instead of an arbitrary implementation. Fixes #1927 Signed-off-by: Ilya Brykau --- src/pipeline/pass_parallel.c | 26 +++++++- tests/test_pipeline.c | 120 +++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 0a43a3f5e..6fffffea0 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2092,6 +2092,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) { @@ -2131,7 +2154,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]; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index a5d5b545f..666b4674d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4886,6 +4886,125 @@ TEST(pipeline_go_multi_init_nodes_survive) { 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(); +} + /* 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 @@ -13310,6 +13429,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_go_method_caller_keeps_lsp_join); RUN_TEST(pipeline_go_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_parallel_field_hint_requires_owner_segment); RUN_TEST(pipeline_go_multi_init_nodes_survive); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); From a5a836cf7269a10b8f7278a10727893a73e42895 Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 02/36] refactor(elixir): read a def head through its guard from one helper `def name(args) when guard` does not parse as a call with a guard beside it. tree-sitter-elixir builds one `when` binary_operator for the whole head, and the `name(args)` call that names the function is that operator's left operand. Four walks read that same node, the def's first argument, and each asks a different question of it: the defs walk names the function from it; the unified walk opens the function's call scope from it; the usages walk bounds the head's bindings with it; the calls walk tells a definition head apart from an invocation by it. A private copy in one of them makes the rest disagree about what a guarded clause is. The disagreement is silent, and it mints an edge: a head one walk treats as a declaration and another does not is recorded as a call to the very function being defined. Two questions, so two exported entry points: cbm_elixir_def_head_unwrap_guard(first_arg) returns the head UNDER every guard, or the node unchanged when there is none. The three walks that need a name or a span take this. cbm_elixir_def_head_is(signature, node) is true for that head OR for any `when` operator above it. The calls walk takes this, because a guarded head reaches it by two routes and it sees only one node per visit: `def f(x) when g` arrives as the inner `f(x)` call, and `def f when g` has no inner call at all, so the `when` operator itself arrives. Recognising one route and not the other leaves the phantom in place for the other. Both test the operator TOKEN rather than the node kind. `def a + b` is a binary_operator head too, and peeling it would name the function after its own left parameter. This commit changes no extraction behaviour: nothing calls either helper yet. The walks are separate commits, each with its own measurement. Measured while writing the test, which corrects a claim worth not repeating: tree-sitter-elixir nests multiple guards RIGHT-associatively. `def f(x) when is_atom(x) when is_binary(x)` parses as `when(f(x), when(is_atom(x), is_binary(x)))`, so the head is the outer operator's left operand and one peel reaches it. The peel still runs to a fixed point (one extra token compare), so the helper's contract is "the head under every guard" even where one peel would do. In tests/test_extraction.c, the test elixir_def_head_is_covers_the_head_and_every_guard_above_it drives the helpers on the AST directly rather than through cbm_extract_file. It has to: the two routes into the calls walk are the pair a single-walk test cannot separate, because each walk only ever sees the node its own traversal reaches. It pins the unguarded case, both guarded shapes, the double guard, and that `def a + b` is returned unchanged with its left operand answering false. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/helpers.c | 87 +++++++++++++++++++++ internal/cbm/helpers.h | 20 +++++ tests/test_extraction.c | 164 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 50c75bdf2..6ddd8ceaf 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1140,6 +1140,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 905de04a8..c18f27429 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -103,6 +103,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/tests/test_extraction.c b/tests/test_extraction.c index afbdee26f..46f51feeb 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -16,6 +16,8 @@ #include "result_spill.h" #include "pipeline/pass_lsp_cross.h" #include "iris_export_xml.h" +#include "helpers.h" +#include "lang_specs.h" /* ── Helpers ───────────────────────────────────────────────────── */ @@ -1246,6 +1248,167 @@ 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(); +} + /* 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 @@ -8487,6 +8650,7 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); + RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it); RUN_TEST(elixir_call_string_argument); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); From 630f851526dfdd3ce5672e267598c0eeb5a2155a Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 03/36] fix(elixir): extract a def head wrapped in a `when` guard extract_elixir_func_def reads the def's first argument directly and accepts only a `call` or an `identifier` there. A guard makes that argument a binary_operator, so the extractor found no name and returned. Every guarded clause was dropped, and a function whose clauses ALL carry guards never reached the graph at all. The loss is silent: a missing definition is not an error anywhere in the pipeline. Guards are ordinary Elixir. 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` and `edges` rows out of the two SQLite stores: Function nodes 20,882 -> 21,941 new qualified names 1,059 (functions with no node at all before) lost qualified names 0 CALLS edges 55,851 -> 57,129 USAGE edges 27,418 -> 28,223 Every one of those 1,059 is a function whose every clause carries a guard: a clause without one already minted the node they collided on. An agent asking "who calls this" about any of them got an empty answer that reads exactly like dead code. The unwrap goes in the shared helper. The calls, unified and usages walks read the same node, and a private copy in one of them makes the rest disagree about what a guarded clause is. Two regressions come with the new node. Neither is minted here: both are pre-existing phantoms that had nowhere to land while guarded clauses were dropped, and now have a node. 1. A phantom CALLS edge onto the function being defined. The calls walk decides a def head is a declaration by comparing the visited node against the def's first argument, and a guard makes that argument the `when` operator, so the inner `name(args)` call no longer compares equal. cbm.c flags self-recursion by line containment, so the phantom lands inside the function's own span: self_recursive Function nodes 129 -> 1,342 ...with no self-call form in span 24 -> 1,241 (18.6% -> 92.5%) (measured by reading each flagged node's own source span and looking for any non-declaration line that names it as a call, a capture or a pipe target.) 2. A phantom WRITES edge onto it, from a paren-less guarded head. `def f when g` has no inner call at all, so the `when` operator reaches the read/write extractor and its first identifier is recorded as an assignment target. Reproduced on `def bare_guarded when true, do: :ok`: one CBMReadWrite row, var_name "bare_guarded", is_write true, scoped to the file's Module node (the graph asserting that a module mutates a function). Corpus WRITES 11,220 -> 11,302 (+82). Both become observable with this commit, and the guarded call-scope commit later in this stack closes them, by different mechanisms. The CALLS phantom goes because that commit admits the `when` operator to the definition-container check, so a paren-less head is recognised as a declaration. The WRITES phantom goes because that commit opens a function scope for the clause: the read/write row is still recorded, but its enclosing_func_qn becomes the guarded clause itself rather than the enclosing module, so resolve_rw_edges finds src->id == tgt->id and drops the self-edge. They are stated here because a stack that lands partially must not hide them. tests/test_extraction.c: extract_elixir_guarded_def_head covers every guarded shape (one guard, two guards, a `defp`, a paren-less `def bare when true`) and both operator-definition forms that must NOT be unwrapped (`def a + b`, `def c - d when is_integer(c)`, neither of which is extracted, as before). It fails on the current tree for each of the guarded names. Depends on the shared def-head helper commit. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/extract_defs.c | 10 ++++++++ tests/test_extraction.c | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 0f376b960..cdd6b1c7f 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5435,6 +5435,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) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 46f51feeb..f8b61d53c 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1409,6 +1409,54 @@ TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it) { 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(); +} + /* 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 @@ -8651,6 +8699,7 @@ 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(elixir_call_string_argument); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); From 77d4beb7a8ad9a6b8486ee9196731dd46de69e9e Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 04/36] fix(elixir): span a multi-clause function's node over all of its clauses 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 breaks a same-QN collision by keeping the largest start_line, so the survivor was whichever clause was written last, 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 `def admin?(%__MODULE__{}), do: false`, so an agent asking what the function does is told the predicate always returns false. One node per function is right, so the fix widens that node's span instead of splitting each clause onto its own node. Two bounds keep the widened span honest, and a clause folds only if it clears both: it must sit in the same module body as the previous one (an Elixir QN carries no module, so `Outer.run` and `Outer.Inner.run` compute the same name), and it must be adjacent to it (only the immediately preceding EXTRACTED definition is a candidate, so a def of another name ends the group). 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` and `edges` rows out of the two SQLite stores: 4,135 functions stop being reported as one line of source, and none starts, which is the get_code_snippet defect measured on this corpus. A widened span is only honest if nothing phantom lives inside it, which is why the head suppression in extract_calls.c lands with the fold rather than as a follow-up. A guarded clause head is a declaration of the name, never a call to it, and the calls walk did not agree: it suppressed a head only when the head node IS the def's first named argument, and `def f(x) when g` parses its whole head as a `when` binary_operator, so the inner `f(x)` was not that argument. It now asks cbm_elixir_def_head_is, the same helper the defs walk reads the head through. cbm.c flags self-recursion by line containment, so under the folded span that phantom would land inside the function's own node, and `recursive` is a queryable node property that seeds the cycle detection in pass_complexity. The widened span has a real cost. Reading each flagged node's own source span for any line that names it as a call, a capture or a pipe target, and counting a one-line clause's body after `, do:` as such a line: self_recursive Function nodes 129 -> 430 ...with no self-call form in span 6 -> 48 The lines responsible are the typespec heads a widened span now covers: `@spec f(t) :: u` puts the declared name in a `call` node and the unified walk reads it as code. That is a separate defect with its own fix later in this stack, and a fold cannot close it. Two things a reader of the graph will notice: Edge metadata moves on edges that SURVIVE. `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 another row and the surviving edge takes that row's `line`. Of the 119,696 edges that survive this commit, 1,054 do. The 2,436 that go are 2,104 CALLS and 332 USAGE, measured against this commit's parent, and none is added back. is_exported becomes the OR over the folded clauses. The arity-free QN puts a public entry point and its private accumulator on one node, and a name callable from outside must stay exported. Exactly 6 Function nodes change is_exported on this corpus, 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 correct. The typespec attributes put a declared name in a `call` node too and are deliberately NOT suppressed in the calls walk. A def head can be, because is_elixir_def_binding already treats a def's first argument as a binding, so the identifier handle_calls declines is declined by handle_usages too. Nothing treats a typespec subject as a binding, so declining one here would not remove its phantom, it would RELABEL it as a USAGE: a separate row under UNIQUE(source, target, type) that no longer collides with the real call it used to hide behind. extract_elixir_declaration_head_mints_no_reference_of_any_kind pins that as a relation rather than a count, because at extraction the relabel is invisible. Depends on the shared def-head helper and guarded def-heads commits. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/extract_calls.c | 55 +++++- internal/cbm/extract_defs.c | 130 +++++++++++++- tests/test_extraction.c | 338 +++++++++++++++++++++++++++++++++++ 3 files changed, 515 insertions(+), 8 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index e1de896b8..e74801b47 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -708,6 +708,59 @@ 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 the whole first argument + * as a binding occurrence rather than a reference, which is what keeps a + * head suppressed here from re-emerging as a USAGE edge. It is deliberately + * WIDER than the unwrap -- it covers the guard expression too -- and that + * width is load-bearing: a suppression in one extractor of the unified walk + * only REMOVES a phantom if the others also decline the node. + * - extract_unified.c, compute_elixir_func_qn, resolves a def's QN for call + * scope tracking and does NOT unwrap, so a guarded clause pushes no + * function scope and its call edges are sourced from the File rather than + * from the enclosing Function. Pre-existing, independent of the two above + * (it decides edge SOURCE, not edge existence), and fixed separately. + * + * 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}; @@ -725,7 +778,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; } diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index cdd6b1c7f..839e482de 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5422,8 +5422,110 @@ 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. 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)) { @@ -5456,15 +5558,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); } @@ -5501,6 +5613,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); @@ -5523,7 +5639,7 @@ 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, "defmodule") == 0) { TSNode do_block = emit_elixir_module_class(ctx, cur); if (!ts_node_is_null(do_block)) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index f8b61d53c..887457cde 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1457,6 +1457,338 @@ TEST(extract_elixir_guarded_def_head) { 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 of this patch, and pins it as a RELATION + * rather than a count, because the count cannot see the defect. A typespec + * subject is the same shape as a def head and is NOT suppressed here: nothing + * in extract_usages.c treats it as a binding, so declining it in handle_calls + * only relabels its phantom as a USAGE. At extraction that relabel is + * invisible -- one reference before, one after -- and it becomes a defect + * downstream, where CALLS and USAGE are separate rows under + * UNIQUE(source,target,type) and the relabelled phantom stops colliding with + * the real call it used to hide behind. So the assertion is that every + * reference recorded for a typespec subject is a CALL and none has become a + * usage, a read/write or a type reference. */ +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")); + /* Declared, not suppressed here; see the comment above. Every reference + * this file records for a typespec subject is a CALL -- none of them has + * been relabelled into a usage, a read/write or a type reference. */ + ASSERT_EQ(count_calls_named(r, "spec_subject"), + elixir_any_ref_count(r, "spec_subject")); + 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(); +} + /* 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 @@ -8700,6 +9032,12 @@ SUITE(extraction) { 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(elixir_call_string_argument); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); From 198e211c3001e33538f67a1cadb623cbd550bf08 Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 05/36] fix(elixir): open a function scope for a guarded clause body A guard wraps the whole head in a `when` binary_operator, and three walks that read a def's first argument were not expecting one. The three failures are independent, and none is an error at runtime: The unified walk resolves a def's QN to open the function's call scope, and compute_elixir_func_qn accepts only a `call` or an `identifier` there. Handed a binary_operator it returns NULL, no function scope is pushed, and every call in a guarded body is attributed to the File node. The function reports no outgoing edges at all, and each of its callees gains an in-edge from the file. So "what does this function call" answers empty for a guarded clause, and "who calls this" answers with a file. The calls walk tells a definition head apart from an invocation through cbm_elixir_def_head_is, which covers the head and the `when` operators above it. A paren-less guarded clause (`def bare_guarded when true, do: ...`) never reaches that check: call_node_is_definition_container only admitted a `call` node, and here the head is the operator itself. Elixir's call node types include binary_operator, so the operator reached extract_callee_name and, with no callee of its own, took that function's last resort, the first identifier child, minting a phantom CALLS edge onto the very function being defined. The usages walk asks whether an identifier sits inside the def's signature and treats everything that does as a binding site rather than a reference. A guard makes that signature the whole `when` operator, which spans the guard expression too, so a parameter read by the guard was classified as its own binding and emitted no USAGE row at all. A guard is an expression over parameters that `guarded(a, b)` has already bound, so every mention of one to its right is a read, exactly as the same mention in the body already is. Measured by indexing one 970-file Elixir lib tree with the binary built from the base, 5df8b044, and with this one, and diffing the `edges` rows out of the two SQLite stores: Counted as the cumulative effect of the first four commits against that base, because this commit's attribution change is only observable once guarded clauses have nodes to be attributed to: CALLS sourced from a Module node 11,061 -> 5,840 (-5,221) CALLS sourced from a Function node 42,659 -> 49,579 (+6,920) CALLS sourced from a File node 2,131 -> 1,269 (-862) Calls written inside a guarded body stop being attributed to the enclosing module and are attributed to the function that writes them. The File count falls rather than rises, because the guarded-def-heads commit earlier in this stack gives those functions a node to source from. Against this commit's own parent, 5,825 USAGE rows appear for parameters read inside a guard that had been classified as their own binding. Node counts are identical in the two stores: the change moves where an edge comes from and records reads that were dropped. This also closes the WRITES phantom disclosed in the guarded-def-heads commit, though the extraction-level row does not go away. `def bare_guarded when true` still records one CBMReadWrite with var_name "bare_guarded" and is_write true. Its scope changes: with a function scope now open, enclosing_func_qn is the guarded clause where it had been the enclosing module, so resolve_rw_edges finds src->id == tgt->id and drops the self-edge. Measured on that exact fixture: before, one row scoped to `t.store`; after, one row scoped to `t.store.bare_guarded`. Corpus WRITES at this commit is 12,098 against 11,220 on the base; the typespec commit at the end of this stack brings it to 11,207. The row is still wrong at extraction and would reappear as an edge the moment anything sourced it elsewhere; removing it means teaching the read/write extractor about `when`, which is a fourth walk and belongs in its own change. tests/test_extraction.c: two tests. extract_elixir_guarded_def_head_scope asserts call attribution by exact scope QN rather than by callee name, because a call the scope walk failed to place carries the FILE QN, and a bare count of the callee passes just as happily with every call homed on the file. The same test pins the file-node count at 0. extract_elixir_guarded_def_head_guard_usages asserts each name both within its scope and in total, so an unwrap that took the guard instead of the head (admitting the binding sites themselves) fails on the totals. Only a `when` operator is admitted to the definition-container check. The generic first-identifier last resort still overrides a deliberate NULL for every other Elixir binary_operator that extract_scripting_callee declines (`=`, `<-`, `->`, `\\`, `::`), so `def g(c \\ 2)` still emits a phantom `c` and `e = target(d)` a phantom `e`. That is pre-existing behaviour for those operators and is untouched here. Depends on the shared def-head helper and multi-clause span commits. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/extract_calls.c | 47 +++++++++--- internal/cbm/extract_unified.c | 5 ++ internal/cbm/extract_usages.c | 5 ++ tests/test_extraction.c | 132 +++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 12 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index e74801b47..c4891c8e0 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -725,17 +725,24 @@ static bool elixir_call_head_in(TSNode call, const char *source, const char *con * 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 the whole first argument - * as a binding occurrence rather than a reference, which is what keeps a - * head suppressed here from re-emerging as a USAGE edge. It is deliberately - * WIDER than the unwrap -- it covers the guard expression too -- and that - * width is load-bearing: a suppression in one extractor of the unified walk - * only REMOVES a phantom if the others also decline the node. - * - extract_unified.c, compute_elixir_func_qn, resolves a def's QN for call - * scope tracking and does NOT unwrap, so a guarded clause pushes no - * function scope and its call edges are sourced from the File rather than - * from the enclosing Function. Pre-existing, independent of the two above - * (it decides edge SOURCE, not edge existence), and fixed separately. + * - 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 @@ -800,7 +807,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_unified.c b/internal/cbm/extract_unified.c index 086f501a0..68e0fbdcc 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -602,6 +602,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) { diff --git a/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index f6b391315..57594c665 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/tests/test_extraction.c b/tests/test_extraction.c index 887457cde..1adca8bce 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -91,6 +91,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++) { @@ -1457,6 +1498,95 @@ TEST(extract_elixir_guarded_def_head) { 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 @@ -9039,6 +9169,8 @@ SUITE(extraction) { RUN_TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind); RUN_TEST(extract_elixir_real_recursion_survives_head_suppression); 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(haskell_function); RUN_TEST(ocaml_function); RUN_TEST(erlang_function); From 4d21c3bbd34b420c8987e8588b88b5fc5446116f Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:26:22 -0700 Subject: [PATCH 06/36] fix(elixir): stop walking @spec and @type subtrees as code An Elixir module attribute parses as `unary_operator(@, call(, args))`, so the typespec family reaches the unified walk in exactly 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, sourced at the enclosing Module. 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, every specced function carries an inbound edge from its own module, so fan_in never reaches 0 and "which exported functions does nothing call" is unanswerable. Measured by indexing one 970-file Elixir lib tree before and after: every specced function loses the inbound reference its own typespec minted, and no node is added or removed. This is one of two mechanisms. It removes the phantom a typespec line mints; the def-head phantom is removed by the calls walk recognising a `when`-wrapped head as a declaration, earlier in this stack. A function carrying both an @spec and a `when` clause needs both, which is why this commit is ordered after that one and its test can assert 0 references onto such a function rather than pinning a remaining 1. The skip covers the whole subtree, and two costs come with that. Both were measured by indexing a fixture repo (a declaring file per attribute head plus the file each one reaches) with the pristine binary and with this one: A typespec's type references are not indexed at all. That loses nothing correct: a type declaration mints no node, so such a reference resolves onto nothing or onto a same-named FUNCTION, which is itself the pollution. `@spec build(MyApp.User.t())` where that module's `t` is a `@type` produced no edge in either build; the ones that did resolve landed on a same-named Function, cross-file included. @callback and @macrocallback lose a real relationship. The declared name is a function name, so it resolved cross-file onto the functions implementing the behaviour, and nothing replaces that edge: `@behaviour MyBehaviour` mints none of its own. It goes anyway because it is only name resolution: 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. A typespec head a widened span covers was also being read as a self-call, which is the other half of the cost the multi-clause span commit disclosed. Same 970-file tree, same method it used (each flagged node's own source span, counting a one-line clause's body after `, do:` as a line that names the function): self_recursive Function nodes 430 -> 389 ...with no self-call form in span 48 -> 8 The base is 129 and 6. All 8 that remain are the phantom a local variable sharing the function's name mints (`defp slug(value) do slug = ...`), which nothing in this stack touches: 6 are on the base as well, and the other 2 are guarded clauses that only have a node of their own to be flagged on because of the earlier commits here. `unquote(...)` inside a typespec does execute at compile time, so `@type t :: unquote(build_type())` loses its genuine `build_type` edge too. Every other attribute VALUE is ordinary compile-time code (`@timeout Application.compile_env(:app, :timeout)` really does call compile_env), so those subtrees are deliberately walked exactly as before, and their attribute NAME still mints one phantom onto a same-named Function. That half of the defect class needs its own fix. tests/test_extraction.c: extract_elixir_typespec_attribute_is_not_code exercises all six heads in the skip list, so deleting any one entry breaks it. It pins the two accepted costs as decisions, and it carries a control name (`without_spec`) that reads 0 on the pristine build too, so an instrument that scored a definition as a reference could not make the other zeroes vacuous. extract_elixir_declaration_head_mints_no_reference_of_any_kind tightens from a relation to 0: with both mechanisms present, a function with an @spec and a guard carries no reference of any kind. Depends on the guarded call-scope commit, for the ordering above. It touches no file the earlier commits in this stack touch except extract_unified.c, and none of their lines. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- internal/cbm/extract_defs.c | 15 +++- internal/cbm/extract_unified.c | 110 ++++++++++++++++++++++++++- tests/test_extraction.c | 135 ++++++++++++++++++++++++++++----- 3 files changed, 235 insertions(+), 25 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 839e482de..2faf10856 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5477,9 +5477,18 @@ static TSNode elixir_call_args(TSNode node) { // 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. 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. +// 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 diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 68e0fbdcc..9467d82e6 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -2041,6 +2041,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 @@ -2631,7 +2736,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) -- @@ -2661,7 +2767,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/tests/test_extraction.c b/tests/test_extraction.c index 1adca8bce..3f6544853 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1847,24 +1847,22 @@ static int elixir_any_ref_count(CBMFileResult *r, const char *name) { } /* 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. + * 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 of this patch, and pins it as a RELATION - * rather than a count, because the count cannot see the defect. A typespec - * subject is the same shape as a def head and is NOT suppressed here: nothing - * in extract_usages.c treats it as a binding, so declining it in handle_calls - * only relabels its phantom as a USAGE. At extraction that relabel is - * invisible -- one reference before, one after -- and it becomes a defect - * downstream, where CALLS and USAGE are separate rows under - * UNIQUE(source,target,type) and the relabelled phantom stops colliding with - * the real call it used to hide behind. So the assertion is that every - * reference recorded for a typespec subject is a CALL and none has become a - * usage, a read/write or a type reference. */ + * `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 */ @@ -1886,11 +1884,19 @@ TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind) { /* 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")); - /* Declared, not suppressed here; see the comment above. Every reference - * this file records for a typespec subject is a CALL -- none of them has - * been relabelled into a usage, a read/write or a type reference. */ + /* 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(); } @@ -1919,6 +1925,94 @@ TEST(extract_elixir_real_recursion_survives_head_suppression) { 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 @@ -9168,6 +9262,7 @@ SUITE(extraction) { 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); From 2443e92bece270d1207c715962707faee23387c3 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:21:46 -0700 Subject: [PATCH 07/36] fix: preserve CPU headroom during background indexing Fixes #1084 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- src/daemon/application.c | 11 ++-- src/mcp/mcp.c | 6 ++- src/pipeline/pipeline.c | 35 +++++++++++-- src/pipeline/pipeline.h | 12 +++++ src/pipeline/pipeline_incremental.c | 6 +-- tests/test_daemon_application.c | 6 +++ tests/test_mcp.c | 68 ++++++++++++++++++++++++ tests/test_pipeline.c | 80 +++++++++++++++++++++++++++++ 8 files changed, 210 insertions(+), 14 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 22c62029b..91b5937ec 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -2199,10 +2199,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, "_background", true); + char *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL; yyjson_mut_doc_free(document); return args; } @@ -3894,7 +3894,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, "_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/mcp/mcp.c b/src/mcp/mcp.c index c877d5110..0d15ac017 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -11109,7 +11109,8 @@ static char *index_run_supervised_path(cbm_mcp_server_t *srv, const char *root_p yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); 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_policy_add_to_args(doc, root, &policy) || + !yyjson_mut_obj_add_bool(doc, root, "_background", true)) { yyjson_mut_doc_free(doc); return NULL; } @@ -11498,6 +11499,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, "_background"); cbm_normalize_path_sep(repo_path); if (!repo_path) { @@ -11660,6 +11662,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { free(repo_path); return cbm_mcp_text_result("failed to create pipeline", true); } + cbm_pipeline_set_background(p, background); if (name_override && name_override[0] && !cbm_pipeline_set_project_name(p, name_override)) { cbm_pipeline_free(p); mcp_project_mutation_end(srv, mutation_project); @@ -18106,6 +18109,7 @@ static void *autoindex_thread(void *arg) { cbm_log_warn("autoindex.err", "msg", "pipeline_create_failed"); return NULL; } + cbm_pipeline_set_background(p, true); /* Block until any concurrent pipeline finishes */ cbm_pipeline_lock(); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index ba41b877c..f2aeb47be 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -71,6 +71,18 @@ 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; @@ -192,6 +204,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). @@ -400,6 +413,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; @@ -613,12 +632,18 @@ void cbm_pipeline_set_committed_counts(cbm_pipeline_t *p, int nodes, int edges) * 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 { + workers = cbm_default_worker_count(!p || !p->background); } - 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(). */ @@ -2590,7 +2615,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; } @@ -2690,7 +2715,7 @@ static int run_extraction_phase(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, return CBM_NOT_FOUND; } - int worker_count = effective_worker_count(true); + int worker_count = cbm_pipeline_worker_count(p); CBM_PROF_START(t_extract_total); int rc = (worker_count > SKIP_ONE && file_count > MIN_FILES_FOR_PARALLEL) ? run_parallel_pipeline(p, ctx, files, file_count, worker_count, &t) diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index c1d571ecd..e52513673 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -59,6 +59,18 @@ void cbm_pipeline_set_resource_policy(cbm_pipeline_t *p, const cbm_index_resourc /* 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); + +#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 diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 154518658..4e7d61dc3 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1212,7 +1212,7 @@ 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_default_worker_count(true); + int worker_count = cbm_pipeline_worker_count(ctx->pipeline); bool use_parallel = closure != NULL || (worker_count > SKIP_ONE && ci > MIN_FILES_FOR_PARALLEL_INCR); @@ -1590,7 +1590,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 = (char **)calloc((size_t)probe_count, sizeof(char *)); @@ -2061,7 +2061,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; } diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 7f4c9cc28..50abe5a8c 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1375,6 +1375,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 { @@ -1442,6 +1443,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, "_background"); if (marker_file) { (void)snprintf(context->marker_paths[worker->attempt], APP_TEST_PATH_CAP, "%s", marker_file); @@ -2202,6 +2205,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); @@ -3634,6 +3638,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); @@ -3931,6 +3936,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); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 22af972af..34563e0c9 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -20006,6 +20006,73 @@ TEST(autoindex_limit_guards_git_root_issue713) { PASS(); } +TEST(mcp_auto_index_in_process_uses_background_worker_policy) { + char cache[256]; + char repo[512]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-autoindex-workers-XXXXXX", cbm_tmpdir()); + 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; + + char old_cwd[CBM_SZ_4K] = {0}; + bool cwd_ready = repo_ready && environment_ready && cbm_getcwd(old_cwd, sizeof(old_cwd)) && + cbm_chdir(repo) == 0; + cbm_config_t *config = cwd_ready ? cbm_config_open(cache) : NULL; + bool 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(); + cbm_mcp_server_t *server = config_ready ? cbm_mcp_server_new(NULL) : NULL; + char *response = NULL; + if (server) { + cbm_mcp_server_set_config(server, config); + response = cbm_mcp_server_handle( + server, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); + cbm_mcp_server_free(server); /* joins the automatic index thread */ + } + 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 (cwd_ready) { + (void)cbm_chdir(old_cwd); + } + 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(server_ready); + ASSERT_TRUE(response_ready); + ASSERT_EQ(selected_workers, cbm_default_worker_count(false)); + ASSERT_TRUE(cleaned); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * #853 — auto_watch=false must ALSO gate the SUPERVISED fresh-index * watcher registration (keystone × #849 merge interaction) @@ -21361,6 +21428,7 @@ 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_uses_background_worker_policy); } /* Kept separate so daemon-coordination regressions can be iterated without diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 56be6b88e..32f4b1a4c 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -117,6 +117,84 @@ 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_default_worker_count(false) && + foreground_default >= 1 && background_default >= 1; + + cbm_setenv("CBM_WORKERS", "3", 1); + bool 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(); +} + +TEST(pipeline_background_policy_preserves_index_results) { + ASSERT_EQ(setup_test_repo(), 0); + 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); + ASSERT_NOT_NULL(foreground); + ASSERT_NOT_NULL(background); + cbm_pipeline_set_background(background, true); + + int foreground_rc = cbm_pipeline_run(foreground); + int background_rc = cbm_pipeline_run(background); + int foreground_nodes = -1; + int foreground_edges = -1; + int background_nodes = -1; + int background_edges = -1; + 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(); + + 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); @@ -16284,6 +16362,8 @@ 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_policy_preserves_index_results); RUN_TEST(pipeline_null_repo); RUN_TEST(pipeline_free_null); RUN_TEST(pipeline_cancel); From 9bfd5cbe609e24f2ee77dd05f48aefed2199bfba Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:56:56 -0700 Subject: [PATCH 08/36] fix: route the remaining all-core sites and fix the review findings Addresses the four blockers from review: - Capture the expected worker count while CBM_WORKERS is still unset in mcp_auto_index_in_process_uses_background_worker_policy, so the assertion no longer reads the lane override restored just before it. - Give pipeline_background_policy_preserves_index_results a fixture past MIN_FILES_FOR_PARALLEL and drop any inherited CBM_WORKERS / CBM_INDEX_SINGLE_THREAD pin, so it really compares an all-core index against a headroom index instead of two sequential runs. - Route the two remaining all-core sites through cbm_pipeline_worker_count: the LSP surface pass and incremental manifest hashing. The pipeline is plumbed into cbm_pipeline_build_semantic_manifest for that. - Rename the hidden key _background to _cbm_background, matching the existing _cbm_index_policy convention, across every producer and consumer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Du315CaKLufAYPEdEwcocq Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- src/daemon/application.c | 4 +- src/mcp/mcp.c | 4 +- src/pipeline/lsp_surface.c | 3 +- src/pipeline/pipeline.c | 2 +- src/pipeline/pipeline_incremental.c | 22 ++++++---- src/pipeline/pipeline_internal.h | 6 +-- tests/test_daemon_application.c | 2 +- tests/test_mcp.c | 5 ++- tests/test_pipeline.c | 64 ++++++++++++++++++++++------- 9 files changed, 78 insertions(+), 34 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 91b5937ec..d1f78841b 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -2201,7 +2201,7 @@ static char *application_auto_index_args(cbm_daemon_application_t *application, yyjson_mut_doc_set_root(document, root); 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, "_background", true); + 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; @@ -3895,7 +3895,7 @@ 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) && - yyjson_mut_obj_add_bool(document, root, "_background", true); + 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/mcp/mcp.c b/src/mcp/mcp.c index 0d15ac017..cd7b5c711 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -11110,7 +11110,7 @@ static char *index_run_supervised_path(cbm_mcp_server_t *srv, const char *root_p yyjson_mut_doc_set_root(doc, root); if (!yyjson_mut_obj_add_strcpy(doc, root, "repo_path", root_path) || !cbm_mcp_index_policy_add_to_args(doc, root, &policy) || - !yyjson_mut_obj_add_bool(doc, root, "_background", true)) { + !yyjson_mut_obj_add_bool(doc, root, "_cbm_background", true)) { yyjson_mut_doc_free(doc); return NULL; } @@ -11499,7 +11499,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, "_background"); + bool background = cbm_mcp_get_bool_arg(args, "_cbm_background"); cbm_normalize_path_sep(repo_path); if (!repo_path) { diff --git a/src/pipeline/lsp_surface.c b/src/pipeline/lsp_surface.c index 4c3cde336..95ed7f63b 100644 --- a/src/pipeline/lsp_surface.c +++ b/src/pipeline/lsp_surface.c @@ -231,8 +231,9 @@ int cbm_lsp_surface_build_rows(const cbm_pipeline_ctx_t *ctx, const char *projec .rows = rows, }; atomic_init(&job.failed, false); + int workers = cbm_pipeline_worker_count(ctx ? ctx->pipeline : NULL); cbm_parallel_for(file_count, surface_row_one, &job, - (cbm_parallel_for_opts_t){.max_workers = 0, .force_pthreads = false}); + (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/pipeline.c b/src/pipeline/pipeline.c index f2aeb47be..6f6655c5f 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -2810,7 +2810,7 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { rc = 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_incremental.c b/src/pipeline/pipeline_incremental.c index 4e7d61dc3..8c348c3f7 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -413,9 +413,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) { @@ -456,7 +456,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)); @@ -634,9 +634,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); @@ -1602,8 +1602,12 @@ 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 = 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 = 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; } diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 7f266eae9..c2a87e0bf 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -808,9 +808,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil #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); diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 50abe5a8c..0bec333a8 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1444,7 +1444,7 @@ static int app_fake_worker_start(void *opaque, const char *args_json, size_t mem 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, "_background"); + 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); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 34563e0c9..d334c4774 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -20057,6 +20057,9 @@ TEST(mcp_auto_index_in_process_uses_background_worker_policy) { if (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 the headroom policy. */ + int expected = cbm_default_worker_count(false); mcp_test_restore_env(environment, sizeof(environment) / sizeof(environment[0])); bool cleaned = !cache_ready || th_rmtree(cache) == 0; @@ -20068,7 +20071,7 @@ TEST(mcp_auto_index_in_process_uses_background_worker_policy) { ASSERT_TRUE(config_ready); ASSERT_TRUE(server_ready); ASSERT_TRUE(response_ready); - ASSERT_EQ(selected_workers, cbm_default_worker_count(false)); + ASSERT_EQ(selected_workers, expected); ASSERT_TRUE(cleaned); PASS(); } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 32f4b1a4c..c541db5e2 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -161,32 +161,68 @@ TEST(pipeline_background_worker_policy_preserves_overrides) { 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); - ASSERT_NOT_NULL(foreground); - ASSERT_NOT_NULL(background); - cbm_pipeline_set_background(background, true); - - int foreground_rc = cbm_pipeline_run(foreground); - int background_rc = cbm_pipeline_run(background); + 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; - cbm_pipeline_get_committed_counts(foreground, &foreground_nodes, &foreground_edges); - cbm_pipeline_get_committed_counts(background, &background_nodes, &background_edges); + 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); @@ -5458,8 +5494,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); From 26eece514fef17bef35cafb23b16c647c3358aaf Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:55:29 -0700 Subject: [PATCH 09/36] fix(mcp): keep full width for a project's first index Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- src/mcp/mcp.c | 23 +++++++- src/pipeline/pipeline.c | 2 +- tests/test_mcp.c | 128 +++++++++++++++++++++++++++++++++------- 3 files changed, 129 insertions(+), 24 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index cd7b5c711..31efa9420 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -11370,6 +11370,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 @@ -11662,7 +11678,6 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { free(repo_path); return cbm_mcp_text_result("failed to create pipeline", true); } - cbm_pipeline_set_background(p, background); if (name_override && name_override[0] && !cbm_pipeline_set_project_name(p, name_override)) { cbm_pipeline_free(p); mcp_project_mutation_end(srv, mutation_project); @@ -11672,6 +11687,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); @@ -18109,7 +18128,7 @@ static void *autoindex_thread(void *arg) { cbm_log_warn("autoindex.err", "msg", "pipeline_create_failed"); return NULL; } - cbm_pipeline_set_background(p, true); + 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/pipeline.c b/src/pipeline/pipeline.c index 6f6655c5f..be881942e 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -204,7 +204,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 */ + 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). diff --git a/tests/test_mcp.c b/tests/test_mcp.c index d334c4774..f42f0dc6a 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -20006,10 +20006,43 @@ TEST(autoindex_limit_guards_git_root_issue713) { PASS(); } -TEST(mcp_auto_index_in_process_uses_background_worker_policy) { +/* 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-autoindex-workers-XXXXXX", cbm_tmpdir()); + (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]; @@ -20029,24 +20062,57 @@ TEST(mcp_auto_index_in_process_uses_background_worker_policy) { 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 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 = repo_ready && environment_ready && cbm_getcwd(old_cwd, sizeof(old_cwd)) && - cbm_chdir(repo) == 0; - cbm_config_t *config = cwd_ready ? cbm_config_open(cache) : NULL; - bool 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(); - cbm_mcp_server_t *server = config_ready ? cbm_mcp_server_new(NULL) : NULL; + bool cwd_ready = true; + cbm_config_t *config = NULL; + bool config_ready = true; + cbm_mcp_server_t *server = NULL; char *response = NULL; - if (server) { - cbm_mcp_server_set_config(server, config); - response = cbm_mcp_server_handle( - server, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); - cbm_mcp_server_free(server); /* joins the automatic index thread */ + 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; @@ -20054,12 +20120,12 @@ TEST(mcp_auto_index_in_process_uses_background_worker_policy) { free(response); cbm_config_close(config); - if (cwd_ready) { + 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 the headroom policy. */ - int expected = cbm_default_worker_count(false); + * expected count follow the lane override instead of this policy. */ + int expected = cbm_default_worker_count(!committed); mcp_test_restore_env(environment, sizeof(environment) / sizeof(environment[0])); bool cleaned = !cache_ready || th_rmtree(cache) == 0; @@ -20069,6 +20135,7 @@ TEST(mcp_auto_index_in_process_uses_background_worker_policy) { 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); @@ -20076,6 +20143,22 @@ TEST(mcp_auto_index_in_process_uses_background_worker_policy) { 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) @@ -21431,7 +21514,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_uses_background_worker_policy); + 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); } /* Kept separate so daemon-coordination regressions can be iterated without From 0fd8180699b06502cd53d1a33e085f2c525ce131 Mon Sep 17 00:00:00 2001 From: Cristian Castillo Date: Wed, 14 Oct 2026 11:16:00 -0500 Subject: [PATCH 10/36] fix(cli): leave a user-owned OpenCode module untouched instead of appending the block The generated OpenCode adapter is an auto-loaded module, so it is installed as a managed block rather than a whole-file write. When the file on disk carried content but none of our markers, the upsert appended the block anyway, even though the block's own header tells readers to "remove the markers to take ownership". The appended prelude redeclares module-level names the user's own module also declares -- `import { spawn }`, `const BIN`, `function augment` -- and both sides declare a default export once the block itself carries one, so the result cannot be parsed at all: SyntaxError: Identifier 'spawn' has already been declared The client then drops the whole extension with only a load warning to show for it. A hand-port around #2204 is therefore not a durable end state. Probe block ownership before writing. A marker-less file that carries content is left alone and reported in both the real run and the dry run, instead of being turned into an unparseable hybrid. An absent, empty, whitespace-only, or already-ours file still installs or refreshes exactly as before. Refs #2204 Signed-off-by: Cristian Castillo --- src/cli/cli.c | 22 +++++++++++ src/cli/config_text_edit.c | 38 ++++++++++++++++++ src/cli/config_text_edit.h | 10 +++++ tests/test_cli.c | 74 +++++++++++++++++++++++++++++++++++ tests/test_config_text_edit.c | 39 ++++++++++++++++++ 5 files changed, 183 insertions(+) diff --git a/src/cli/cli.c b/src/cli/cli.c index b5ddc074d..a77794df6 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -9399,8 +9399,30 @@ 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 (g_install_plan) { plan_record(label, "extension", path); + if (foreign) { + 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); + } + return; + } + if (foreign) { + printf(" extension: %s left untouched — the file there is not ours (no " + "codebase-memory-mcp markers)\n", + path); return; } char *content = generate(binary_path); diff --git a/src/cli/config_text_edit.c b/src/cli/config_text_edit.c index ad728f506..d4e23e0a4 100644 --- a/src/cli/config_text_edit.c +++ b/src/cli/config_text_edit.c @@ -1105,6 +1105,44 @@ 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; + } + char *data = NULL; + size_t len = 0U; + text_file_snapshot_t snapshot; + if (text_read_file(file_path, &data, &len, &snapshot) != TEXT_OK) { + free(data); + return TEXT_ERROR; + } + int result = TEXT_OK; + if (snapshot.exists && text_validate_bytes(data, len, 1) != TEXT_OK) { + result = TEXT_ERROR; + } else if (snapshot.exists) { + size_t bom_len = text_bom_length(data, len); + text_managed_region_t region; + if (text_scan_managed_region(data, len, bom_len, begin_marker, begin_len, end_marker, + end_len, ®ion) != TEXT_OK) { + result = TEXT_ERROR; + } else if (!region.present) { + for (size_t pos = bom_len; pos < len; pos++) { + char byte = data[pos]; + if (byte != ' ' && byte != '\t' && byte != '\r' && byte != '\n') { + result = TEXT_UNOWNED; + break; + } + } + } + } + free(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/tests/test_cli.c b/tests/test_cli.c index 354cc0026..d0b8c492d 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -9220,6 +9220,79 @@ 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. */ +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; + } + if (redirected) { + (void)cbm_install_agent_configs(home, "/opt/other/codebase-memory-mcp", false, true); + fflush(stdout); + (void)dup2(saved_stdout, STDOUT_FILENO); + } + if (saved_stdout >= 0) + close(saved_stdout); + 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); + } + + 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); + bool warned = strstr(output, "cbm-augment.ts") != NULL && strstr(output, "not ours") != NULL; + bool untouched = got == sizeof(foreign) - 1U && memcmp(bytes, foreign, got) == 0; + + test_rmdir_r(home); + if (!redirected) + FAIL("stdout capture failed"); + if (!warned) + FAIL("dry-run must predict an untouched user-owned OpenCode module (#2204)"); + if (!untouched) + FAIL("the user-owned OpenCode module must not be modified (#2204)"); + PASS(); +} #endif TEST(cli_agent_client_registry_routes_plan_install_and_uninstall) { @@ -18360,6 +18433,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_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); From 6bb7acdf3af41e6fc317f7f581c80170ffe67b5a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 00:42:36 +0200 Subject: [PATCH 11/36] refactor(cli): share the managed-block read step with the ownership probe cbm_text_managed_block_status() repeated the read, validate and scan step of text_upsert_managed_block() with two raw free() calls of its own, which put src/cli/config_text_edit.c over its memory-core baseline (72 -> 74). Both now go through text_read_managed_document(), so the probe and the writer judge a file by the same rules, and the file is back at 72. An absent file still reads as an empty document, so the probe returns the same results as before. Signed-off-by: Martin Vogel --- src/cli/config_text_edit.c | 90 ++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/src/cli/config_text_edit.c b/src/cli/config_text_edit.c index d4e23e0a4..b89a20b94 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,17 @@ 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; + size_t bom_len = doc.bom_len; + text_file_snapshot_t snapshot = doc.snapshot; + text_managed_region_t region = doc.region; text_buffer_t updated = {0}; int result = TEXT_ERROR; @@ -1113,33 +1145,23 @@ int cbm_text_managed_block_status(const char *file_path, const char *begin_marke text_validate_markers(begin_marker, end_marker, &begin_len, &end_len) != TEXT_OK) { return TEXT_ERROR; } - char *data = NULL; - size_t len = 0U; - text_file_snapshot_t snapshot; - if (text_read_file(file_path, &data, &len, &snapshot) != TEXT_OK) { - free(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; } int result = TEXT_OK; - if (snapshot.exists && text_validate_bytes(data, len, 1) != TEXT_OK) { - result = TEXT_ERROR; - } else if (snapshot.exists) { - size_t bom_len = text_bom_length(data, len); - text_managed_region_t region; - if (text_scan_managed_region(data, len, bom_len, begin_marker, begin_len, end_marker, - end_len, ®ion) != TEXT_OK) { - result = TEXT_ERROR; - } else if (!region.present) { - for (size_t pos = bom_len; pos < len; pos++) { - char byte = data[pos]; - if (byte != ' ' && byte != '\t' && byte != '\r' && byte != '\n') { - result = TEXT_UNOWNED; - break; - } + 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(data); + free(doc.data); return result; } From 3f612f6b710f6592a7612537f197909483ce9480 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 00:42:36 +0200 Subject: [PATCH 12/36] fix(cli): keep a user-owned OpenCode module out of the install plan With --plan, install_generated_client_extension() recorded the extension before the ownership check and printed the skip notice on stdout. The receipt therefore listed a write the run would not make, and `install --plan` no longer printed valid JSON when a user-owned module was present. For a user-owned module, plan mode now records and prints nothing. The dry run prints the "would be skipped" notice instead, alongside the hook-script notice from #1387, and the real run keeps its "left untouched" line. The cli test now also checks the receipt and plan-mode stdout, with a control that the receipt does list the module once it is no longer user-owned. Refs #2204 Signed-off-by: Martin Vogel --- src/cli/cli.c | 21 ++++++++++++++------- tests/test_cli.c | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index 9abd26f9a..2aae26080 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -9415,20 +9415,27 @@ static void install_generated_client_extension(const char *label, const char *pa * 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 (g_install_plan) { - plan_record(label, "extension", path); - if (foreign) { + 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 (foreign) { - printf(" extension: %s left untouched — the file there is not ours (no " - "codebase-memory-mcp markers)\n", - path); + if (g_install_plan) { + plan_record(label, "extension", path); return; } char *content = generate(binary_path); diff --git a/tests/test_cli.c b/tests/test_cli.c index 96936d0dd..d13002cd9 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -9259,13 +9259,33 @@ TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) { fflush(stdout); redirected = dup2(fileno(capture), STDOUT_FILENO) >= 0; } + long plan_output_start = -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); (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}; @@ -9276,13 +9296,14 @@ TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) { fclose(capture); } - 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); bool warned = 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 plan_silent = plan_output_start >= 0 && (size_t)plan_output_start <= strlen(output) && + strstr(output + plan_output_start, "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) @@ -9291,6 +9312,12 @@ TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) { FAIL("dry-run must predict an untouched user-owned OpenCode module (#2204)"); if (!untouched) FAIL("the user-owned OpenCode module must not be modified (#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 From fbfcfee30a187244ad07af3eabdb9db1d431f866 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 02:50:32 +0200 Subject: [PATCH 13/36] style(cli): narrow bom_len to the branch that uses it text_upsert_managed_block read doc.bom_len into a function-scope local, but only the "no managed region yet" branch uses it. cppcheck 2.20 (the CI lint image) flags that as variableScope. Declare it inside the else branch instead; doc stays in scope and unmodified, so behaviour is unchanged. Signed-off-by: Martin Vogel --- src/cli/config_text_edit.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/config_text_edit.c b/src/cli/config_text_edit.c index b89a20b94..38bea7b4d 100644 --- a/src/cli/config_text_edit.c +++ b/src/cli/config_text_edit.c @@ -1080,7 +1080,6 @@ static int text_upsert_managed_block(const char *file_path, const char *begin_ma } char *old_data = doc.data; size_t old_len = doc.len; - size_t bom_len = doc.bom_len; text_file_snapshot_t snapshot = doc.snapshot; text_managed_region_t region = doc.region; @@ -1102,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); From c363d8f0f542a8dcf95f9565e12c38daec204ae4 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 04:35:02 +0200 Subject: [PATCH 14/36] test(elixir): pin the clause fold inside compile-time if/else variants A multi-clause function defined in both branches of a module-level if must fold each branch's clauses into one definition and keep the two branches as variants of one identity: one pick node, CALLS to both branch bodies, and exactly the variant spans 5-6 and 8-9. Without the fold the node carries one span per clause (4); without the if/unless branch walk from main there is no pick node at all. Covers the combination the main merge brought together. Signed-off-by: Martin Vogel --- tests/test_conditional_variants.c | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) 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); From a909f8418e9fb0fe52372d82f92c41a53e2f6fe7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:27:00 +0200 Subject: [PATCH 15/36] fix(daemon): let an explicit index join a running automatic job Automatic jobs (session auto-index, watcher re-index) now carry _cbm_background, and the job registry decides whether a new request may join the job already running for its project by comparing the index arguments after normalising defaults. An explicit index_repository for the same root therefore stopped matching the automatic job and was refused with "another index operation for this project is active with different options" -- the refusal the repo_path separator fold (9846c3f1) removed on Windows, now on every platform. _cbm_background chooses the worker count and never what is indexed, so drop it from the comparison the same way default-valued options are dropped. The explicit request joins the running job again. The new daemon_application test holds an auto-index job open with the fake worker, checks that its args carry the marker, and sends an explicit async index_repository for the same root. Without this change it is refused as an options conflict; with it the request joins (one worker start, one active job). The async fixture gains an optional stored config so the test can enable auto_index. Refs #1084 Signed-off-by: Martin Vogel --- src/daemon/application.c | 3 ++ tests/test_daemon_application.c | 86 +++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 92e5cb873..5c17aa639 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -1943,6 +1943,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; } diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 811cd31e0..4a8696824 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -6215,8 +6215,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){ @@ -6227,7 +6228,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; @@ -6248,6 +6250,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); @@ -6625,6 +6632,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); @@ -6953,6 +7032,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); From 1d7ce9ba6ac0294d7e1a2ef3a3ae3c9c0259b996 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 22:09:31 +0200 Subject: [PATCH 16/36] fix(pipeline): cap surface callbacks at the worker budget Signed-off-by: Martin Vogel --- src/pipeline/lsp_surface.c | 50 ++++++++++++++- src/pipeline/lsp_surface.h | 7 ++ src/pipeline/worker_pool.c | 21 ++++++ src/pipeline/worker_pool.h | 7 ++ tests/test_pipeline.c | 127 +++++++++++++++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 2 deletions(-) diff --git a/src/pipeline/lsp_surface.c b/src/pipeline/lsp_surface.c index 5b9e283b6..66f6f3b25 100644 --- a/src/pipeline/lsp_surface.c +++ b/src/pipeline/lsp_surface.c @@ -483,10 +483,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; @@ -526,6 +528,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, @@ -547,10 +588,15 @@ 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); int workers = cbm_pipeline_worker_count(ctx ? ctx->pipeline : NULL); - cbm_parallel_for(file_count, surface_row_one, &job, + 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 */ 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/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/tests/test_pipeline.c b/tests/test_pipeline.c index 1c7544631..9a364e941 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" @@ -164,6 +165,129 @@ TEST(pipeline_background_worker_policy_preserves_overrides) { 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 @@ -27721,6 +27845,9 @@ SUITE(pipeline) { /* Lifecycle */ RUN_TEST(pipeline_create_free); RUN_TEST(pipeline_background_worker_policy_preserves_overrides); +#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); From 001fbe269ac5ed0a0667a8d235c5e95ad0eff19e Mon Sep 17 00:00:00 2001 From: 0xb007ab1e <134006168+0xb007ab1e@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:55:57 -0500 Subject: [PATCH 17/36] fix(adr): store ADRs in a per-project sidecar DB, untouched by reindex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the ADR-loss-across-reindex fix to Alternative 1 (per the PR review): remove the race rather than narrow it. ADRs live in a per-project sidecar SQLite DB (".adr.db"), separate from the graph DB a reindex rebuilds or deletes, so there is no capture, re-read, re-apply, or lock — and no sleeps, polling, or timeout deciding an outcome. store.c: - ADR writes (store/delete/update_sections) use a cached read-write handle opened SQLITE_OPEN_READWRITE|CREATE and configured like the graph store (PRAGMA busy_timeout=10000 + journal_mode=WAL) so cross-process access waits rather than failing SQLITE_BUSY. A :memory: store keeps ADRs in the graph DB. - Reads never write: cbm_store_adr_get uses the cached RW handle if open, else opens the sidecar SQLITE_OPEN_READONLY (with the same busy timeout) when the file exists, else reads the legacy graph row without creating anything. When the sidecar exists but is NOT marked migrated (half-created), it falls back to the legacy graph row so a partial sidecar never hides an ADR. - One-time migration is atomic: adr_copy_legacy_once wraps the marker check, legacy copy, and marker insert in a single BEGIN IMMEDIATE...COMMIT (re-checking the marker under the lock; ROLLBACK->ERR on failure). New public cbm_store_adr_migrate_once. Sidecar path via cbm_path_for_file_api (Windows long-path). Dropped the write-only adr_db_path field. pipeline.c: migrate a legacy ADR into the sidecar BEFORE the format-change and forced-full routes unlink the old generation; preserve-and-abort on failure, and do NOT fail open — a query-open returning NULL preserves unless the final DB genuinely does not exist. Migration targets the FINAL path (staging suffix stripped). The early-capture/re-apply machinery and its test hook are gone. artifact.c: the exported artifact carries the ADR — snapshot_inject_adr always DELETEs project_summaries first, then injects only when an ADR is present, so a deleted ADR never resurrects through a lingering legacy row. Import restores the ADR into the destination sidecar via cbm_store_adr_restore_from_graph_if_absent: it copies the artifact's ADR (INSERT OR IGNORE — never clobbers a local one) regardless of the migrated marker, so a teammate who already indexed the project (marked sidecar, no ADR) still receives the shared decision record. store.c factors the row copy into adr_copy_rows, shared by the one-time legacy migration and the ungated restore. mcp.c: delete_project removes ".adr.db" (+ -wal/-shm/-journal) only after the graph unlink succeeds; is_project_db_file skips *.adr.db. cli.c: exclude *.adr.db from index listing/counting. README + memory-core baselines updated. Tests (file-based): ADR written mid-reindex survives on BOTH the full and delta routes (route-pinned FORCED_FULL / CLOSURE_REPAIR); a legacy row migrates before a forced-full/format reindex with no prior read; migration failure (a directory at .adr.db) preserves-and-aborts with the old generation intact; delete_project via the MCP handler removes the sidecar + journals; the artifact export->import round trip keeps the ADR; a deleted ADR does not resurrect on import; an import does not clobber a local ADR; an import into an already-indexed destination (marked sidecar, no ADR) restores the artifact's ADR; a half-created sidecar falls back to the legacy row. The import tests remove the destination graph DB (keeping the sidecar + marker) before importing, matching production (try_artifact_bootstrap imports only when the graph DB is missing) so the rename succeeds on Windows, not only on POSIX. Closes #516 Signed-off-by: 0xb007ab1e <134006168+0xb007ab1e@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- scripts/memory-core-baseline.txt | 4 +- src/cli/cli.c | 31 +- src/foundation/str_util.c | 8 + src/mcp/mcp.c | 32 +- src/pipeline/artifact.c | 86 ++++- src/pipeline/pipeline.c | 105 +++--- src/pipeline/pipeline_incremental.c | 52 +-- src/pipeline/pipeline_internal.h | 2 - src/store/store.c | 402 ++++++++++++++++++--- src/store/store.h | 11 + tests/test_artifact.c | 189 ++++++++++ tests/test_mcp.c | 52 +++ tests/test_pipeline.c | 538 ++++++++++++++++++++++++---- tests/test_store_arch.c | 17 + 15 files changed, 1300 insertions(+), 231 deletions(-) diff --git a/README.md b/README.md index 7cdac83bf..1beb10435 100644 --- a/README.md +++ b/README.md @@ -875,7 +875,7 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a |------|-------------| | `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh only while it is the project of an open MCP session; repos indexed from the CLI or from another project's session need a re-run (`index_status` shows `watch`). 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/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index d3c3ef8bd..4ab89d320 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 1ebe1687e..760b4319f 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7325,6 +7325,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) { @@ -7339,6 +7346,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++; @@ -7362,9 +7370,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); @@ -7378,6 +7395,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); @@ -10604,7 +10626,8 @@ static int count_db_indexes(const char *home) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { - if (cbm_is_project_index_db(ent->name)) { + if (cbm_is_project_index_db(ent->name) && + !cli_is_adr_sidecar(ent->name, strlen(ent->name))) { count++; } } diff --git a/src/foundation/str_util.c b/src/foundation/str_util.c index 00c6b4e68..76b30deb2 100644 --- a/src/foundation/str_util.c +++ b/src/foundation/str_util.c @@ -317,7 +317,9 @@ bool cbm_is_internal_cache_db(const char *filename) { bool cbm_is_project_index_db(const char *filename) { static const char db_ext[] = ".db"; + static const char adr_ext[] = ".adr.db"; const size_t ext_len = sizeof(db_ext) - 1; + const size_t adr_len = sizeof(adr_ext) - 1; if (!filename) { return false; } @@ -325,6 +327,12 @@ bool cbm_is_project_index_db(const char *filename) { if (len <= ext_len || strcmp(filename + len - ext_len, db_ext) != 0) { return false; } + /* A per-project ADR sidecar (".adr.db") ends in ".db" but is not itself + * an index — exclude it from every index scan/count (MCP loops + the CLI's + * cbm_list_indexes / count_db_indexes all gate on this helper). */ + if (len >= adr_len && strcmp(filename + len - adr_len, adr_ext) == 0) { + return false; + } return !cbm_is_internal_cache_db(filename); } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 5bd02e4e3..35ab94fa8 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -766,7 +766,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\"]}"}, @@ -7508,7 +7508,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) { @@ -7547,18 +7549,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; } @@ -20097,9 +20118,10 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { } } - /* ADRs are stored in the SQLite store (project_summaries), the SAME - * backend the UI /api/adr endpoints use — so writes via the MCP tool and - * the UI are visible to each other (#256). */ + /* ADRs are stored in the per-project ADR sidecar DB (".adr.db"), the + * SAME backend the UI /api/adr endpoints use (via cbm_store_adr_*) — so + * writes via the MCP tool and the UI are visible to each other (#256), and + * a reindex of the graph DB never disturbs them. */ store_recovery_status_t recovery_status = STORE_RECOVERY_NONE; cbm_store_t *resolved = resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status, true); diff --git a/src/pipeline/artifact.c b/src/pipeline/artifact.c index 0d6ef3010..7be8f6950 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); @@ -1198,6 +1257,17 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { cbm_unlink(wal); cbm_unlink(shm); + /* 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. */ + cbm_store_t *restore = cbm_store_open_path_existing(cache_db_path); + if (restore) { + (void)cbm_store_adr_restore_from_graph_if_absent(restore); + cbm_store_close(restore); + } + 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/pipeline.c b/src/pipeline/pipeline.c index 698d9dc7a..a19ca549f 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -78,7 +78,6 @@ static atomic_int g_pipeline_busy = 0; 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; @@ -96,10 +95,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; @@ -155,7 +150,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; @@ -270,10 +264,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 @@ -1068,9 +1058,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; @@ -2728,38 +2715,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); @@ -2865,11 +2853,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"); @@ -2882,9 +2878,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); @@ -3406,10 +3406,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. */ @@ -3688,7 +3686,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 = @@ -3740,8 +3737,6 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas cbm_log_warn("index.ignored_capped", "stored", itoa_buf(p->ignored_count), "total", itoa_buf(p->ignored_total)); } - free(p->saved_adr); - p->saved_adr = NULL; free(db_path); return 0; diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index ce1c89f6f..a327046df 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -2170,8 +2170,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, @@ -2185,7 +2184,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}, @@ -3724,7 +3722,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 = @@ -4112,43 +4109,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); @@ -4308,7 +4270,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); @@ -4421,7 +4382,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); @@ -4457,12 +4417,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 27c37098d..52489f140 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1665,7 +1665,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; @@ -1918,7 +1917,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/store/store.c b/src/store/store.c index d19e52890..6061ea657 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; @@ -1488,6 +1504,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); } @@ -11739,7 +11760,257 @@ 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 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. */ +/* 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; +} + +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. */ +static int adr_restore_if_absent(cbm_store_t *s, sqlite3 *adb) { + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + if (adr_copy_rows(s, adb) != CBM_STORE_OK) { + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; + } + /* Mark migrated too, so a later reindex's before-delete migration is a no-op. */ + (void)sqlite3_exec(adb, "INSERT OR IGNORE INTO adr_meta (key, value) VALUES ('migrated','1');", + NULL, NULL, NULL); + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + (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)); @@ -11749,8 +12020,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); @@ -11762,22 +12033,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); @@ -11786,26 +12052,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) { @@ -11821,13 +12087,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"); @@ -11836,16 +12100,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; @@ -11863,13 +12173,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; } @@ -11877,7 +12194,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; } @@ -11892,7 +12209,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; } @@ -11906,7 +12223,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; } @@ -11917,7 +12234,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; } @@ -11926,19 +12243,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 0f291daf9..f498583ee 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -1141,6 +1141,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..bb7990c9e 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,175 @@ 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); + + 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(); +} + TEST(artifact_export_best_roundtrip) { setup_artifact_test(); create_test_db(g_db); @@ -1172,6 +1357,10 @@ 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_export_best_roundtrip); RUN_TEST(artifact_exists_check); RUN_TEST(artifact_commit_hash); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 80a320ca5..e6f383bb7 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -9056,6 +9056,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()); @@ -25401,6 +25452,7 @@ SUITE(mcp_mutation_guard) { RUN_TEST(tool_ingest_traces_mutation_guard_missing_store_releases); RUN_TEST(tool_ingest_traces_mutation_guard_query_failure_releases); RUN_TEST(tool_delete_project_mutation_guard_blocks_then_releases); + RUN_TEST(tool_delete_project_removes_adr_sidecar); RUN_TEST(tool_index_repository_mutation_guard_blocks_before_local_worker); RUN_TEST(tool_manage_adr_mutation_guard_balances_success); RUN_TEST(tool_manage_adr_read_paths_skip_blocking_mutation_guard); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 75e5cb43d..07d8a79e9 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -770,6 +770,217 @@ TEST(pipeline_adr_survives_full_reindex) { 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"); @@ -5860,6 +6071,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. */ @@ -6817,7 +7082,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, }; @@ -8368,79 +8632,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(); } @@ -28608,7 +29008,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); RUN_TEST(pipeline_full_reindex_quarantines_corrupt_destination_without_overwrite); RUN_TEST(pipeline_full_reindex_replaces_legacy_schema_without_quarantine); diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 8ed3f4636..36dad3ef0 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); @@ -1912,6 +1928,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); From db74648c22b9f057a16ae25814d5e762c415ab32 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 25 Oct 2026 21:03:17 +0100 Subject: [PATCH 18/36] fix(pipeline): keep background refreshes on the parallel route A background refresh used performance cores minus one, which is a single worker on a host with two or fewer performance cores. 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). Floor the background width at two whenever the foreground width is two or more; a host whose foreground width is one keeps one. CBM_WORKERS and CBM_INDEX_SINGLE_THREAD keep their precedence, and larger hosts keep their headroom unchanged. The pipeline and mcp worker-policy tests now expect the floored width, and a new pipeline test pins the width for one-core, two-core and larger host shapes. Signed-off-by: Martin Vogel --- src/pipeline/pipeline.c | 21 ++++++++++++++++++++- src/pipeline/pipeline.h | 5 +++++ tests/test_mcp.c | 4 +++- tests/test_pipeline.c | 35 +++++++++++++++++++++++++++++++---- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 5474e8f5e..99bf23f6e 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1437,6 +1437,22 @@ 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 @@ -1447,8 +1463,11 @@ int cbm_pipeline_worker_count(const cbm_pipeline_t *p) { int workers; if (st && st[0] == '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(!p || !p->background); + workers = cbm_default_worker_count(true); } #if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API atomic_store(&g_last_worker_count, workers); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index a9ac97935..e7222e4dd 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -79,6 +79,11 @@ void cbm_pipeline_set_background(cbm_pipeline_t *p, bool background); * 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); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 2d6ba3ea3..e20de2034 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -23303,7 +23303,9 @@ static int mcp_assert_inprocess_worker_policy(bool committed, bool automatic) { } /* Capture while CBM_WORKERS is still unset. Restoring first makes the * expected count follow the lane override instead of this policy. */ - int expected = cbm_default_worker_count(!committed); + 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; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 96825bcca..e9c636d83 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -140,12 +140,19 @@ TEST(pipeline_background_worker_policy_preserves_overrides) { 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_default_worker_count(false) && - foreground_default >= 1 && background_default >= 1; + 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); - bool env_override = cbm_pipeline_worker_count(foreground) == 3 && - cbm_pipeline_worker_count(background) == 3; + 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 && @@ -165,6 +172,25 @@ TEST(pipeline_background_worker_policy_preserves_overrides) { 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 }; @@ -28325,6 +28351,7 @@ SUITE(pipeline) { /* 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 From 158a0c9da2919f5ad43d03d99528c9cd5a3d2044 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 26 Oct 2026 02:22:58 +0100 Subject: [PATCH 19/36] test(cli): assert a real install leaves a user-owned module untouched The byte check in cli_extension_leaves_user_owned_module_untouched_issue2204 ran only after a dry run and the plan receipt, and neither of them writes, so it could not catch the #2204 append. The test now also runs a real install over the user-owned module, then checks that the file is byte-identical and that the run reports it as left untouched. Each phase's stdout checks are bounded to that phase's slice of the capture, so no phase can pass on another's line. With the early return that ends the user-owned branch of install_generated_client_extension() dropped, the test now fails; before this change it still passed. Refs #2204 Signed-off-by: Martin Vogel --- tests/test_cli.c | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/test_cli.c b/tests/test_cli.c index d13002cd9..6381e58d9 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -9227,7 +9227,8 @@ TEST(cli_dry_run_predicts_refused_hook_script_issue1387) { * 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. */ + * 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]; @@ -9260,6 +9261,7 @@ TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) { 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); @@ -9270,6 +9272,10 @@ TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) { 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) @@ -9296,11 +9302,22 @@ TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) { fclose(capture); } - bool warned = strstr(output, "cbm-augment.ts") != NULL && strstr(output, "not ours") != NULL; + /* 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 plan_silent = plan_output_start >= 0 && (size_t)plan_output_start <= strlen(output) && - strstr(output + plan_output_start, "cbm-augment.ts") == NULL; bool control_lists = control_plan && strstr(control_plan, "cbm-augment.ts") != NULL; free(plan); free(control_plan); @@ -9308,10 +9325,14 @@ TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) { 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("the user-owned OpenCode module must not be modified (#2204)"); + 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) From 4316e93c2be30d3cbabe02ccf3263ce15bbc0da0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 23:44:34 +0100 Subject: [PATCH 20/36] test(pipeline): pass the pipeline argument in the skip-locked manifest test Main added the manifest-skip-locked test (9f57edfa) with the old cbm_pipeline_build_semantic_manifest signature. This branch gives the function a leading pipeline parameter, so the tree merged from main failed to compile. Pass NULL like the branch manifest-fail-closed test already does. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 24ba07cd4..9cdfb17e3 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8750,7 +8750,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; From dda8f8a953be727526c9d5a255744cfb151b9631 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 28 Oct 2026 21:49:08 +0100 Subject: [PATCH 21/36] test(pipeline): restore the tests split by the merge resolution Main and this branch added tests at the same spot of tests/test_pipeline.c. Joining the two conflict sides lost the closing lines git had moved out of the conflict, so a test function was left open and the file did not compile. Rebuild the file as main plus this branch own changes to it. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9db2b026e..7c3734f6b 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1019,6 +1019,10 @@ TEST(pipeline_records_unresolved_injected_call_sites) { } cbm_store_free_coverage(rows, count); cbm_store_close(st); + rm_rf(tmp); + 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). */ From b94a7d7a32fea4f1ad7988e71450a1280d905a99 Mon Sep 17 00:00:00 2001 From: Divakant Pandey Date: Thu, 29 Oct 2026 19:39:31 +0530 Subject: [PATCH 22/36] fix(cli): recognize symlinked agent config directories during detection (#2671) Agent detection used POSIX lstat in dir_exists and rejected symlinks in cbm_agent_registry_path_exists, causing dotfiles setups with symlinked configuration directories (e.g. ~/Library/Application Support/Zed or ~/.cursor) to be reported as undetected and skipped during install. Use stat so valid directory symlinks are followed and detected while preserving dangling symlink rejection. Signed-off-by: Divakant Pandey --- src/cli/cli.c | 8 ---- tests/test_cli.c | 99 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index 3db818c28..280fbfed1 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -2719,11 +2719,7 @@ int cbm_remove_zed_mcp_owned(const char *binary_path, const char *config_path) { static bool dir_exists(const char *path) { struct stat st; -#ifndef _WIN32 - return lstat(path, &st) == 0 && S_ISDIR(st.st_mode); -#else return stat(path, &st) == 0 && S_ISDIR(st.st_mode); -#endif } /* Resolve the Claude Code config dir. @@ -9301,11 +9297,7 @@ static const char *cbm_agent_registry_env_path(const char *env_name, const char static bool cbm_agent_registry_path_exists(const char *path, const void *context) { (void)context; struct stat state; -#ifndef _WIN32 - return path && path[0] && lstat(path, &state) == 0 && !S_ISLNK(state.st_mode); -#else return path && path[0] && stat(path, &state) == 0; -#endif } static bool cbm_agent_registry_command_exists(const char *command, const void *context) { diff --git a/tests/test_cli.c b/tests/test_cli.c index e3383be78..18b243ab6 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -6180,6 +6180,103 @@ TEST(cli_zed_mcp_jsonc_comments) { PASS(); } +TEST(cli_install_agent_config_symlinks_issue2671) { + /* Issue #2671: dotfiles checkout pattern where agent config directory or + * config file is a 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); + ASSERT_EQ(symlink(dotfiles_zed, zed_link), 0); +#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); + ASSERT_EQ(symlink(dotfiles_zed, zed_link), 0); +#endif + + 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); + + 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); + + test_rmdir_r(tmpdir); + ASSERT_EQ(rc, 0); + ASSERT(has_cbm); + ASSERT(has_original); + 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 + + 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); + + 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(); +} + /* ═══════════════════════════════════════════════════════════════════ * PATH management tests (port of TestCLI_InstallPATHAppend) * ═══════════════════════════════════════════════════════════════════ */ @@ -19154,6 +19251,8 @@ SUITE(cli) { RUN_TEST(cli_zed_mcp_preserves_settings); RUN_TEST(cli_zed_mcp_uninstall); RUN_TEST(cli_zed_mcp_jsonc_comments); + RUN_TEST(cli_install_agent_config_symlinks_issue2671); + RUN_TEST(cli_install_agent_config_file_symlink_issue2671); /* PATH management (3 tests) */ RUN_TEST(cli_ensure_path_append); From 192fdb918f29c0ccfe8c030446e8fbb5ff116537 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 30 Oct 2026 22:55:31 +0100 Subject: [PATCH 23/36] test(windows): try FLS init for the MinGW test allocator (R32 experiment) The Windows test leg (CLANG64 x86_64, ASan+UBSan) has a nondeterministic crash that only shows on trees with mimalloc 3.5.3: 1-3 suites die in their first test with 0xC0000005 and no sanitizer report. mimalloc 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. As an experiment, compile the MinGW TEST allocator with MI_WIN_INIT_USE_FLS=1. That build carries none of those hooks: process init runs once from the priority-101 constructor and thread-done goes through a fiber-local destructor. FLS also needs MI_DEBUG=1, because its internal assertion that the FLS key exists fires on the first thread init, which runs before the key is allocated, and that would abort every MI_DEBUG=2 process at startup. Level 1 keeps the fill patterns, padding and double-free checks. The production allocator flags (MIMALLOC_CFLAGS) are unchanged on every platform, and on macOS and Linux MIMALLOC_CFLAGS_TEST is byte-identical. Signed-off-by: Martin Vogel --- Makefile.cbm | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Makefile.cbm b/Makefile.cbm index 3c7398292..aa5aa0796 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -680,6 +680,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 From c548d8e46a820cb3eb27de55fb1d0a54dab628c1 Mon Sep 17 00:00:00 2001 From: Divakant Pandey Date: Sat, 31 Oct 2026 14:16:04 +0530 Subject: [PATCH 24/36] fix(pipeline): infer HTTP method for Angular and verb-suffix client calls (#2768) Signed-off-by: Divakant Pandey --- internal/cbm/service_patterns.c | 7 ++ src/pipeline/pass_calls.c | 15 ++- src/pipeline/pass_cross_repo.c | 10 ++ src/pipeline/pass_parallel.c | 25 ++++- src/pipeline/pass_route_nodes.c | 3 + tests/test_cross_repo.c | 187 ++++++++++++++++++++++++++++++++ tests/test_pipeline.c | 95 ++++++++++++++++ 7 files changed, 332 insertions(+), 10 deletions(-) diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 7d428b8e3..35313452b 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -46,6 +46,13 @@ 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/common/http", CBM_SVC_HTTP, NULL}, + {"angular.common.http", CBM_SVC_HTTP, NULL}, + {"HttpClient", CBM_SVC_HTTP, NULL}, + {"httpClient", CBM_SVC_HTTP, NULL}, + {"this.http", CBM_SVC_HTTP, NULL}, + {"this.httpClient", CBM_SVC_HTTP, NULL}, + {"$http", CBM_SVC_HTTP, NULL}, {"superagent", CBM_SVC_HTTP, NULL}, {"needle", CBM_SVC_HTTP, NULL}, {"node-fetch", CBM_SVC_HTTP, NULL}, diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 1df1fe155..2a9549f26 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -939,15 +939,20 @@ static int calls_emit_unresolved_service(cbm_pipeline_ctx_t *ctx, const CBMCall const char **imp_keys, const char **imp_vals, int imp_count, const char *route_mount, CBMLanguage lang, CBMCallEvidence *evidence) { - if (calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, - route_mount, lang, evidence)) { - return SKIP_ONE; - } cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name); if (svc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { svc = CBM_SVC_HTTP; } - return calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence); + if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) { + if (calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence)) { + return SKIP_ONE; + } + } + if (calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, + route_mount, lang, evidence)) { + return SKIP_ONE; + } + return 0; } /* Apply the field-call policy after a registry target has been found. */ 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 4dbfae9e9..f495e8d5b 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2591,19 +2591,34 @@ 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); + 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; } diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 23dff8150..4e19f06ef 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -113,6 +113,9 @@ static void route_edge_visitor(const cbm_gbuf_edge_t *edge, void *userdata) { json_extract(edge->properties_json, "method", method_buf, sizeof(method_buf)); const char *broker = json_extract(edge->properties_json, "broker", broker_buf, sizeof(broker_buf)); + if (!method && strcmp(edge->type, "HTTP_CALLS") == 0 && callee) { + method = cbm_service_pattern_http_method(callee); + } /* Build Route QN */ char route_qn[CBM_ROUTE_QN_SIZE]; diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 9d318f8c0..7c72681d2 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -944,10 +944,197 @@ 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. */ +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", "GET") && + cross_repo_seed_client_call(&fixture, "angular-2768", "getJob", + "this.http.get", "/items/job/10", "GET"); + 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_pipeline.c b/tests/test_pipeline.c index 94edecc29..919dbbdb2 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -14939,6 +14939,99 @@ 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"); + + 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); + + /* 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); +} + /* #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 @@ -29383,6 +29476,8 @@ 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_url_constant_folding_http_calls_issue706_1147); RUN_TEST(pipeline_folded_topics_preserve_identity_sequential_parallel); RUN_TEST(pipeline_relative_folded_wrapper_url_candidates); From afb499b62a9e328e69e70784ddee7f86746c6588 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 13:56:42 +0100 Subject: [PATCH 25/36] test(pipeline): pin owner-segment field-type hints for C# Go no longer runs the parallel field-type hint (e554a2ab), so the Go test of this PR passes with or without the owner-segment fix. C# still reaches try_field_type_hint, and its PascalCase method names make every candidate QN contain a one-letter receiver's hint: `w.Write()` on a StreamWriter was upgraded to an arbitrary project Write as field_type_hint. The test is RED on main's pass_parallel.c and GREEN with the fix, and its positive control pins `store.Put()` to Store.Put, never FileStore.Put. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 97 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 49d59d064..77196e810 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -11190,6 +11190,102 @@ TEST(pipeline_go_parallel_field_hint_requires_owner_segment) { 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 @@ -29738,6 +29834,7 @@ SUITE(pipeline) { 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); From 4a78fe6a6b5135a4bcf1a4a28d890adf86908405 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 14:16:05 +0100 Subject: [PATCH 26/36] fix(go): a bare init never binds to a package init or an init method Go gives `init` no binding in the package block: init functions cannot be referred to from anywhere in a program, and a method or field named init needs a selector. A bare `init` is therefore always a local name - a closure (`init := func(ctx) {...}; init(ctx)`), a parameter, or the `package init` clause of a directory named init. Before #1915 same_module bound such a name to the flat package init. With per-file init QNs it now falls through to the short-name fallback and binds unrelated methods named init. On kubernetes that minted 24 CALLS (22 e2e storage DefineTests closures among them), 28 WRITES, 7 USAGE and one derived TESTS edge, all false. cbm_go_suppress_bare_init_bind refuses, for a bare Go `init`, any registry binding onto a Function (every Go Function named init is a package init) and a weak short-name binding onto a Method or Field. lsp_* answers and selector calls (`w.init()`) are untouched. Wired at the CALLS, USAGE and READS/WRITES seams of both resolvers. Kubernetes, full index, main vs branch: only those 60 edges removed, nodes identical, no resolver edge added; the 14 LSP-typed selector CALLS into init methods are unchanged. Tests: pipeline_go_bare_init_binds_nothing (+ parallel twin) are RED with the predicate disabled (closure CALLS, parameter CALLS, closure WRITES, package-clause USAGE onto Watcher.init) and GREEN with it; the selector control keeps Watcher.Start/Boot -> Watcher.init. go_bare_init_never_binds_init_namesake pins the predicate. Refs #1915 Signed-off-by: Martin Vogel --- src/pipeline/pass_calls.c | 8 ++ src/pipeline/pass_parallel.c | 19 ++++ src/pipeline/pass_usages.c | 13 +++ src/pipeline/pipeline.h | 10 ++ src/pipeline/registry.c | 40 +++++++ tests/test_pipeline.c | 197 +++++++++++++++++++++++++++++++++++ tests/test_registry.c | 40 +++++++ 7 files changed, 327 insertions(+) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 1df1fe155..3deca9622 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -1344,6 +1344,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_parallel.c b/src/pipeline/pass_parallel.c index 4dbfae9e9..2c264b07d 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3629,6 +3629,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 @@ -3805,6 +3812,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; @@ -3933,6 +3946,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.h b/src/pipeline/pipeline.h index 34261302d..9e90b049c 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -459,6 +459,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/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/tests/test_pipeline.c b/tests/test_pipeline.c index 303af90f7..20a9bf0dc 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -11071,6 +11071,201 @@ 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(); +} + /* 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 @@ -29621,6 +29816,8 @@ SUITE(pipeline) { 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); 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); From 9901feeff10b59bc27364963b9134a9d7e6ce66f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:23:40 +0000 Subject: [PATCH 27/36] build(deps-dev): bump vite Bumps the npm_and_yarn group with 1 update in the /graph-ui directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `vite` from 6.4.4 to 6.4.5 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v6.4.5/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.4.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.4.5 dependency-type: direct:development dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- graph-ui/package-lock.json | 8 ++++---- graph-ui/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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": { From 2acbd3b02857c9cab1e33994525c3e93b5c1f473 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 20:46:20 +0100 Subject: [PATCH 28/36] fix(store): ADR sidecar follow-ups from #2315 review Three small items promised in the #2315 review; behaviour is otherwise unchanged. 1. A failed ADR restore on artifact import is no longer silent (src/pipeline/artifact.c). The import dropped both a failed reopen of the installed DB and the status of cbm_store_adr_restore_from_graph_if_absent, so a team ADR that never reached the sidecar left no trace. The restore moves into artifact_import_restore_adr, which logs level=warn msg=artifact.import err=adr_restore detail= path= on either failure. The import still returns 0: the graph DB is already installed when the restore runs, and the restore stays best-effort as documented. adr_restore_if_absent now sets the store error for its begin, copy and commit failures, so the logged detail names the failing step. 2. The doc comment of adr_copy_legacy_once sat above adr_copy_rows, stacked on top of the comment that documents adr_copy_rows, which left adr_copy_legacy_once undocumented. It now sits above adr_copy_legacy_once (src/store/store.c). 3. adr_restore_if_absent no longer inserts the migrated marker. That INSERT OR IGNORE could never change state. Its only caller, cbm_store_adr_restore_from_graph_if_absent, takes the sidecar handle from adr_conn_write, which hands out a handle (cached or fresh) only after adr_copy_legacy_once returned OK, and that function returns OK only when the marker row is already committed (fast path, or the re-check under the write lock) or it just inserted and committed it. No code deletes an adr_meta row, so the insert hit the existing primary key every time, and its result was discarded anyway. Tests: artifact_import_logs_failed_adr_restore puts a directory at .adr.db (the hook-free trick from the #2315 pipeline test), then asserts the import still succeeds, the graph DB is installed, and the warn line carries the store reason. It is red without the artifact.c change. artifact_export_roundtrip_keeps_adr now also asserts that the fresh destination sidecar is marked migrated after the import, which pins the invariant item 3 relies on. Refs #2315 Signed-off-by: Martin Vogel --- src/pipeline/artifact.c | 33 ++++++++++++++++++-------- src/store/store.c | 25 +++++++++++--------- tests/test_artifact.c | 52 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 21 deletions(-) diff --git a/src/pipeline/artifact.c b/src/pipeline/artifact.c index 7be8f6950..f3b6098ef 100644 --- a/src/pipeline/artifact.c +++ b/src/pipeline/artifact.c @@ -1130,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; @@ -1257,16 +1279,7 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { cbm_unlink(wal); cbm_unlink(shm); - /* 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. */ - cbm_store_t *restore = cbm_store_open_path_existing(cache_db_path); - if (restore) { - (void)cbm_store_adr_restore_from_graph_if_absent(restore); - cbm_store_close(restore); - } + 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/store/store.c b/src/store/store.c index 9e6b6fc32..ed06236a9 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -12010,13 +12010,6 @@ static bool adr_migrated(sqlite3 *adb) { return done; } -/* 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. */ /* 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 @@ -12070,6 +12063,13 @@ static int adr_copy_rows(cbm_store_t *s, sqlite3 *adb) { 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 */ @@ -12106,19 +12106,22 @@ fail: * 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. */ + * 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; } - /* Mark migrated too, so a later reindex's before-delete migration is a no-op. */ - (void)sqlite3_exec(adb, "INSERT OR IGNORE INTO adr_meta (key, value) VALUES ('migrated','1');", - NULL, NULL, NULL); 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; } diff --git a/tests/test_artifact.c b/tests/test_artifact.c index bb7990c9e..abf1415da 100644 --- a/tests/test_artifact.c +++ b/tests/test_artifact.c @@ -252,6 +252,21 @@ TEST(artifact_export_roundtrip_keeps_adr) { 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(); } @@ -391,6 +406,42 @@ TEST(artifact_import_restores_adr_when_local_absent) { 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); @@ -1361,6 +1412,7 @@ SUITE(artifact) { 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); From 22a7dff3ff19a53ba958d1ddc50721bb22356c23 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 20:59:40 +0100 Subject: [PATCH 29/36] fix(service-patterns): end the Angular receiver rows at a separator (#2768) The new HttpClient receiver rows matched by prefix, and an uppercase letter after an id counts as CamelCase glue, so "this.http" also matched this.httpAdapter.get (a NestJS route registration) and this.httpServer.post, and "$http" matched $httpBackend.expectGET (an AngularJS test double). Each receiver row now ends in a dot, like "gin.", plus a line-break twin (LF, and CR for CRLF files): a chain wrapped before the verb keeps the break in its callee text ("this.http\n .post"). On gothinkster/angular-realworld-example-app 5 of the 18 client calls are written that way; with dot-only rows they fell back to phantom route registrations. "HttpClient" duplicated the Java/C# row and "this.httpClient." is covered by "httpClient.". Unit cases pin both directions, and the Angular pipeline test gains a wrapped call on both resolver routes. Signed-off-by: Martin Vogel --- internal/cbm/service_patterns.c | 22 +++++++++++++++++----- tests/test_infrascan.c | 19 +++++++++++++++++++ tests/test_pipeline.c | 8 ++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 35313452b..f6b5a0a00 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -46,13 +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}, - {"HttpClient", CBM_SVC_HTTP, NULL}, - {"httpClient", CBM_SVC_HTTP, NULL}, - {"this.http", CBM_SVC_HTTP, NULL}, - {"this.httpClient", CBM_SVC_HTTP, NULL}, - {"$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/tests/test_infrascan.c b/tests/test_infrascan.c index 3b31f62e1..ef765a3d5 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_pipeline.c b/tests/test_pipeline.c index 919dbbdb2..b7c125086 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -14972,6 +14972,11 @@ static int angular_http_client_typed_case(int fillers) { " 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++) { @@ -15002,6 +15007,9 @@ static int angular_http_client_typed_case(int fillers) { 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); From e12d3db387e7b4d5c95a7fc2033c36b1c3cbce87 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 20:59:47 +0100 Subject: [PATCH 30/36] revert(pipeline): drop two hunks that change no output (#2768) calls_emit_unresolved_service order: resolve_single_call already emits every callee-matched HTTP/ASYNC call with a URL argument before it reaches the empty-resolution fallback, and the global fetch has no route suffix, so the order inside the fallback never decides an edge. route_edge_visitor method fallback: every HTTP_CALLS emitter writes "method" exactly when cbm_service_pattern_http_method(callee) finds a verb, so recomputing it from the stored callee always yields NULL there. Both files return to their main versions. Signed-off-by: Martin Vogel --- src/pipeline/pass_calls.c | 15 +++++---------- src/pipeline/pass_route_nodes.c | 3 --- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 2a9549f26..1df1fe155 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -939,20 +939,15 @@ static int calls_emit_unresolved_service(cbm_pipeline_ctx_t *ctx, const CBMCall const char **imp_keys, const char **imp_vals, int imp_count, const char *route_mount, CBMLanguage lang, CBMCallEvidence *evidence) { - cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name); - if (svc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { - svc = CBM_SVC_HTTP; - } - if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) { - if (calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence)) { - return SKIP_ONE; - } - } if (calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, route_mount, lang, evidence)) { return SKIP_ONE; } - return 0; + cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name); + if (svc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { + svc = CBM_SVC_HTTP; + } + return calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence); } /* Apply the field-call policy after a registry target has been found. */ diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 4e19f06ef..23dff8150 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -113,9 +113,6 @@ static void route_edge_visitor(const cbm_gbuf_edge_t *edge, void *userdata) { json_extract(edge->properties_json, "method", method_buf, sizeof(method_buf)); const char *broker = json_extract(edge->properties_json, "broker", broker_buf, sizeof(broker_buf)); - if (!method && strcmp(edge->type, "HTTP_CALLS") == 0 && callee) { - method = cbm_service_pattern_http_method(callee); - } /* Build Route QN */ char route_qn[CBM_ROUTE_QN_SIZE]; From b9bd036602e18b4a708c9d5682d41c93e341b2e0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 20:59:47 +0100 Subject: [PATCH 31/36] test(cross-repo): seed method-less client edges so the verb fallback binds (#2768) All three seeded client edges carried "GET", so match_http_routes never entered its callee-verb fallback. getItem and getJob are now stored without a method, as an index that did not record the verb writes them. Without the fallback the method-less fuzzy match lands on the DELETE and POST routes seeded first; with it both reach their GET handlers. getItems keeps its explicit GET. Signed-off-by: Martin Vogel --- tests/test_cross_repo.c | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 7c72681d2..0bf14c68b 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -1069,7 +1069,11 @@ static int cross_repo_count_edges_like(const cross_repo_fixture_t *fixture, cons * 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. */ + * 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) && @@ -1088,9 +1092,9 @@ TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) { 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", "GET") && + "this.http.get", "/items/42", NULL) && cross_repo_seed_client_call(&fixture, "angular-2768", "getJob", - "this.http.get", "/items/job/10", "GET"); + "this.http.get", "/items/job/10", NULL); if (!setup) { cross_repo_fixture_end(&fixture); FAIL("failed to seed issue 2768 fixture"); From 6b6213e8cc46e19205b4168061fbc0edebeca3c6 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 20:59:55 +0100 Subject: [PATCH 32/36] fix(pipeline): format the arg-URL verb edge and test the path that reaches it (#2768) clang-format the new snprintf in detect_url_in_args (the line ran past 100 columns, the CI lint failure) and raise eprops to 1K: two escaped 255-byte strings plus the keys and the verb no longer fit in 512. Add a parallel-route test with wrapper calls that actually reach the arg-URL heuristic: a project wrapper whose verb is not a route suffix (this.api.head / this.api.options, URL first) and a route-verb wrapper whose URL sits in a keyword the registration reader does not take as a path (self.session.get(resource=...)). Each gets one HTTP_CALLS edge typed with its verb and no ANY twin. The sequential resolver has no arg-URL heuristic (#2848), so the test is parallel only. Signed-off-by: Martin Vogel --- src/pipeline/pass_parallel.c | 11 ++-- tests/test_pipeline.c | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index f495e8d5b..89cc54b5d 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2610,11 +2610,14 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, 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]; + /* 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); + 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); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b7c125086..cd346936d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15040,6 +15040,102 @@ 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 @@ -29486,6 +29582,7 @@ SUITE(pipeline) { 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); From c0a8ed8b938fc22bf53f1c0543a6fbf7e8b81b9c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 01:07:01 +0100 Subject: [PATCH 33/36] fix(cs-lsp): resolve nested types through type qualifiers, bind global-ns types Follow-up to #2347 (issue #2120). Step 8b binds same-name TOP-LEVEL types by declared namespace, but two shapes still fell through to the short-name fallback, where the prefix score against a path-derived QN is 0 for every variant and registration order decided: 1. A qualified A.B whose qualifier A is a TYPE, not a namespace: new Command.Result(...) and new Grain.Handler(dep).Handle(...). cs_visible_namespaces only tried A as a namespace, and nested types carry no namespace_qn, so the scan never had a candidate. Resolution now falls back to resolving the qualifier as a type (recursively, one segment shorter) and binding the nested member through the registry. 2. A top-level type with no namespace declaration (global namespace): cs_top_level_namespace returned NULL for it, making it invisible to step 8b. Top-level C# types now register namespace_qn "" and cs_visible_namespaces appends the global namespace last. Test: parallel_csharp_nested_type_and_global_namespace_binding covers a nested-type constructor and the member call on its result, a chained constructor receiver, and a same-name type split across the global namespace and an imported namespace, in the sequential pipeline and in the parallel pipeline with the production Tier-2 C# registry. Decoys are registered first. Adapted to current main: the two constructor-edge checks gate the parallel path only, because the sequential constructor edge is still drawn by the textual constructor carrier, a layer this change does not touch. Distilled from #2401: the net change of commits d11be54f and 613060ea. The temporary debug prints added in d11be54f and the stale copy of #2347 the branch carried (already on main as 50d19f85) are not taken. Co-authored-by: Ersin Tarhan Signed-off-by: Martin Vogel --- internal/cbm/lsp/cs_lsp.c | 26 ++++- tests/test_parallel.c | 204 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 2 deletions(-) diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index 023a963af..4c715661c 100644 --- a/internal/cbm/lsp/cs_lsp.c +++ b/internal/cbm/lsp/cs_lsp.c @@ -383,6 +383,11 @@ static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const if (u->kind == CBM_CS_USING_NAMESPACE && u->target_qn && u->target_qn[0]) out[n++] = u->target_qn; } + /* The global namespace is checked last: an unqualified name binds to a + * top-level type declared without a namespace whatever the file imports + * (#2120 residual — such types used to be invisible to step 8b). */ + if (n < cap) + out[n++] = ""; return n; } @@ -421,6 +426,20 @@ 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(...)`). */ + if (!best && qualifier) { + const char *qtype = cs_resolve_type_name(ctx, qualifier); + 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; } @@ -3749,13 +3768,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; - return namespace_name; + /* Top-level C# type: its declared namespace, "" when it declares none + * (global namespace) so step 8b can bind it — NULL stays reserved for + * nested types, which no namespace can reach by simple name. */ + return (namespace_name && namespace_name[0]) ? namespace_name : ""; } /* Register one batch of CBMLSPDef[] into a registry. Shared by the diff --git a/tests/test_parallel.c b/tests/test_parallel.c index b73946cf3..892721228 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3026,6 +3026,209 @@ 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 probe: the SAME simple name resolves through an + * imported namespace AND exists in the global namespace. C# binds the + * imported namespace first, so ImportUser.Run2 must hit the Other variant + * and never the global one — proving the global namespace is appended + * last in cs_visible_namespaces, not merged as an equal. */ + 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"; + 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", + }; + 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}; + enum { N_FILES = 9, N_CHECKS = 10 }; + + 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", other_check, "CALLS"); + ok[g][9] = !has_edge_from_callable_to_qn(gb, "ImportUser.Run2", 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 imported=%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]); + } + } + 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 +5897,7 @@ 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_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); From bc44de15d38195de217511ffe52ef2d7e9155a8b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 01:07:37 +0100 Subject: [PATCH 34/36] fix(cs-lsp): bound the nested-type qualifier walk Resolving a qualified type name A.B that no namespace binds now resolves the qualifier A as a type first (nested types). A using alias whose target is qualified by the alias itself re-enters that walk through the alias step without end: `using Loop = Loop.Inner;` resolves Loop to the target Loop.Inner, whose qualifier is Loop again, and a mutual pair (`using Ping = Pong.Inner; using Pong = Ping.Inner;`) cycles the same way. Both are legal C# when namespaces of those names exist, and the walk overflowed the stack on them. The walk now carries a depth counter in the per-file context and stops after CS_QUALIFIER_MAX_DEPTH (8) levels. One level is one type-nesting step: a namespace-qualified top-level type binds without recursing, so real nesting stays far below the bound. Test: cs_lsp::cslsp_cyclic_alias_qualifier_terminates (self and mutual alias cycles; the file must still resolve Svc.Go). RED without the bound: AddressSanitizer stack-overflow, exit 134. GREEN with it. Signed-off-by: Martin Vogel --- internal/cbm/lsp/cs_lsp.c | 12 ++++++++++-- internal/cbm/lsp/cs_lsp.h | 5 +++++ tests/test_cs_lsp.c | 29 +++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index 4c715661c..978fb5b71 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); @@ -431,9 +435,13 @@ static const char *cs_resolve_by_declared_namespace(CSLSPContext *ctx, const cha * 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(...)`). */ - if (!best && qualifier) { + * `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)) diff --git a/internal/cbm/lsp/cs_lsp.h b/internal/cbm/lsp/cs_lsp.h index 27cc233a5..b7b6c4dc8 100644 --- a/internal/cbm/lsp/cs_lsp.h +++ b/internal/cbm/lsp/cs_lsp.h @@ -111,6 +111,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/tests/test_cs_lsp.c b/tests/test_cs_lsp.c index 6c111ca2d..54184ea09 100644 --- a/tests/test_cs_lsp.c +++ b/tests/test_cs_lsp.c @@ -1226,6 +1226,34 @@ 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(); +} + /* ── Suite ─────────────────────────────────────────────────── */ SUITE(cs_lsp) { @@ -1290,4 +1318,5 @@ 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); } From dad89184cb8831d2b8ce1cab073ad36400a8f25b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 01:12:46 +0100 Subject: [PATCH 35/36] fix(cs-lsp): look up the global namespace in C# spec order C# looks a simple type name up namespace by namespace, innermost outward. At each namespace N it checks the members of N, then the usings of the namespace declaration enclosing the reference. The global namespace is the last N, and its declaration is the compilation unit. So a type declared without a namespace hides a type that only a compilation-unit `using` imports, while a `using` inside a namespace body still wins over the global namespace. Step 8b used to rank every using before the global namespace. - CBMCSUsing records whether a directive sits inside a namespace body (in_namespace). cs_collect_imports tracks it per node, including the compilation-unit siblings after a file-scoped namespace; the cross-file entry pre-loads the pipeline import list without placement and the AST walk upgrades it. - cs_visible_namespaces ranks a simple name as: enclosing namespaces innermost outward, namespace-body usings, the global namespace "", and last the compilation-unit usings. Ranking the global namespace that high also requires that only a type proven global claims it. The vendored grammar does not parse a `ref partial struct` with members, and error recovery dissolves the enclosing namespace, so every type after it in that namespace surfaced with no namespace. Mapped to "" those types outranked the real, imported declaration: on dotnet/runtime 5,313 CALLS moved from implementation files into reference assemblies (System.Runtime.cs, which declares DefaultInterpolatedStringHandler as a `ref partial struct`). cs_type_decl_namespace now returns "" only when the compilation unit parsed without errors and NULL (unknown, invisible to step 8b, as on main) otherwise; cs_top_level_namespace passes that through. Tests: - parallel_csharp_nested_type_and_global_namespace_binding: the compilation-unit `using` probe now expects the global type (spec), and a new namespace-body `using` probe expects the imported one. RED with the previous order: cu_using=0/0. - parallel_csharp_parse_damaged_namespace_is_not_global (sequential and parallel). RED without the clean-parse rule: real=0 no_damaged=0 on both paths. - cs_lsp::cslsp_decl_namespace_global_needs_clean_parse: extractor contract ("" for a clean global type, the namespace when found, never "" after a parse error). Signed-off-by: Martin Vogel --- internal/cbm/cbm.h | 5 +- internal/cbm/extract_defs.c | 17 +++-- internal/cbm/lsp/cs_lsp.c | 91 ++++++++++++++++++------- internal/cbm/lsp/cs_lsp.h | 5 ++ tests/test_cs_lsp.c | 45 +++++++++++++ tests/test_parallel.c | 131 ++++++++++++++++++++++++++++++++---- 6 files changed, 248 insertions(+), 46 deletions(-) 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_defs.c b/internal/cbm/extract_defs.c index 3a476e4b8..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) { diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index 978fb5b71..03345da9f 100644 --- a/internal/cbm/lsp/cs_lsp.c +++ b/internal/cbm/lsp/cs_lsp.c @@ -261,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) @@ -276,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) { @@ -293,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 ────────────────────────────────────────────── */ @@ -362,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; @@ -382,17 +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; - } - /* The global namespace is checked last: an unqualified name binds to a - * top-level type declared without a namespace whatever the file imports - * (#2120 residual — such types used to be invisible to step 8b). */ + 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 n; + return cs_append_using_namespaces(ctx, false, out, n, cap); } /* Bind a (possibly namespace-qualified) type reference to the project type @@ -2824,12 +2848,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); @@ -2913,20 +2944,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; @@ -2935,7 +2969,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; } } } @@ -3782,10 +3820,11 @@ static const char *cs_top_level_namespace(const char *type_qn, const char *short 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, "" when it declares none - * (global namespace) so step 8b can bind it — NULL stays reserved for - * nested types, which no namespace can reach by simple name. */ - return (namespace_name && namespace_name[0]) ? namespace_name : ""; + /* 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; } /* Register one batch of CBMLSPDef[] into a registry. Shared by the diff --git a/internal/cbm/lsp/cs_lsp.h b/internal/cbm/lsp/cs_lsp.h index b7b6c4dc8..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. */ diff --git a/tests/test_cs_lsp.c b/tests/test_cs_lsp.c index 54184ea09..98b1a6e21 100644 --- a/tests/test_cs_lsp.c +++ b/tests/test_cs_lsp.c @@ -1254,6 +1254,50 @@ TEST(cslsp_cyclic_alias_qualifier_terminates) { 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(); +} + /* ── Suite ─────────────────────────────────────────────────── */ SUITE(cs_lsp) { @@ -1319,4 +1363,5 @@ SUITE(cs_lsp) { 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_decl_namespace_global_needs_clean_parse); } diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 892721228..4b67ea200 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3103,11 +3103,16 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { " return u.Check();\n" " }\n" "}\n"; - /* The lookup-order probe: the SAME simple name resolves through an - * imported namespace AND exists in the global namespace. C# binds the - * imported namespace first, so ImportUser.Run2 must hit the Other variant - * and never the global one — proving the global namespace is appended - * last in cs_visible_namespaces, not merged as an equal. */ + /* 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" @@ -3115,7 +3120,17 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { " public string Run2() {\n" " var u = new UpdateService();\n" " return u.Check();\n" - " }\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[] = { @@ -3128,11 +3143,13 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { "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}; - enum { N_FILES = 9, N_CHECKS = 10 }; + 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"); @@ -3200,8 +3217,10 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { 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", other_check, "CALLS"); - ok[g][9] = !has_edge_from_callable_to_qn(gb, "ImportUser.Run2", global_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 @@ -3217,9 +3236,10 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { 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 imported=%d/%d\n", + "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][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]); @@ -3229,6 +3249,88 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { 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; @@ -5898,6 +6000,7 @@ SUITE(parallel) { 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); From 21c253ff63419e5c3de80b9df1dae55877d0cbc5 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 01:13:02 +0100 Subject: [PATCH 36/36] fix(cs-lsp): never pool global-namespace types as partial twins cs_lookup_method searches the other declarations of a type (same simple name, same declared namespace: `partial` pieces, reference-assembly copies) before it walks the base classes. With global-namespace types now carrying the namespace "", every same-name type declared without a namespace anywhere in the repository became a twin of every other one. Across a repository that is a different type per assembly (each test project has its own Program or Helper), so a member missing on the receiver bound to an unrelated type ahead of the receiver's own base. cs_namespace_twins returns no twins for a type in the global namespace. Test: cs_lsp::cslsp_global_namespace_types_are_not_partial_twins runs the production Tier-2 C# registry builder over cross-file defs: two unrelated global Helper types and a base class. RED without the guard: stop_base=0 stop_unrelated_global_twin=1. GREEN with it. Signed-off-by: Martin Vogel --- internal/cbm/lsp/cs_lsp.c | 8 +++- tests/test_cs_lsp.c | 97 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index 03345da9f..266b846a3 100644 --- a/internal/cbm/lsp/cs_lsp.c +++ b/internal/cbm/lsp/cs_lsp.c @@ -661,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; diff --git a/tests/test_cs_lsp.c b/tests/test_cs_lsp.c index 98b1a6e21..c6a58c474 100644 --- a/tests/test_cs_lsp.c +++ b/tests/test_cs_lsp.c @@ -1298,6 +1298,102 @@ TEST(cslsp_decl_namespace_global_needs_clean_parse) { 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) { @@ -1363,5 +1459,6 @@ SUITE(cs_lsp) { 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); }