From c8f36262e18318f38387dd60a779eee5b59aa7fd Mon Sep 17 00:00:00 2001 From: Henry Hazan Date: Wed, 19 Aug 2026 02:02:54 +0300 Subject: [PATCH 01/97] fix(elixir): functions carry no complexity, fingerprint or line count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Elixir Function shipped with complexity 0, cognitive 0, lines 0 and no MinHash fingerprint, so Elixir was invisible to complexity and bottleneck queries and never participated in SIMILAR_TO or duplicate detection — while the query_graph tool description states that "every Function and Method node carries queryable complexity properties". The generic path cannot be reused. cbm_compute_complexity() matches on node TYPE, but Elixir has no branching node types: `if`, `unless`, `case`, `cond`, `with`, `for`, `try` and `receive` are ordinary `call` nodes distinguished only by the identifier in callee position, and every case/cond/fn/rescue arm is a `stab_clause`. That is also why elixir_branch_types is `{"call"}` in lang_specs.c and has never been usable — it would score every function call as a decision. elixir_compute_complexity() is the Elixir-shaped equivalent, one walk with the same Campbell cognitive weighting as the generic version. It counts the branching construct itself, every stab_clause, the short-circuit operators (&&, ||, and, or) and `when` guards. Container and arm are both counted, which is the convention the generic tables already use: Go counts expression_switch_statement plus each expression_case, Python counts try_statement plus each except_clause. A `for` comprehension is the only real loop node; Enum.map/reduce are plain calls and counting them would make loop_depth meaningless. A straight-line single-clause function still scores 0, exactly like every other language's branch-free function. The fingerprint is taken from the do_block rather than the whole def call, so the head does not make every clause of a name look alike. Signed-off-by: Henry Hazan --- internal/cbm/extract_defs.c | 143 ++++++++++++++++++++++++++++++++++++ tests/test_extraction.c | 40 ++++++++++ 2 files changed, 183 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index f9854b03ef..e2822d4463 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -4985,6 +4985,134 @@ static TSNode elixir_call_args(TSNode node) { } // Handle Elixir def/defp/defmacro — extract function definition. +/* True when node `n` spans exactly the text `lit`. Avoids an arena allocation + * per node in the complexity walk, which visits every node of every def. */ +static bool elixir_node_text_is(TSNode n, const char *source, const char *lit) { + if (ts_node_is_null(n)) { + return false; + } + uint32_t s = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + size_t len = strlen(lit); + return (size_t)(e - s) == len && strncmp(source + s, lit, len) == 0; +} + +/* Elixir has no branching NODE TYPES. `if`, `unless`, `case`, `cond`, `with`, + * `for`, `try` and `receive` are ordinary `call` nodes told apart only by the + * identifier in callee position, and every case/cond/fn/rescue arm is a + * `stab_clause`. lang_specs.c cannot express that — the Elixir branch table is + * `{"call"}`, which would score every function call as a decision — and + * cbm_compute_complexity matches on node type, so the generic path is unusable + * and every Elixir function shipped complexity 0. + * + * Counted here: the branching construct itself, every `stab_clause`, the + * short-circuit operators, and `when` guards. Container AND arm are both + * counted, which is the convention the generic tables already use — Go counts + * `expression_switch_statement` plus each `expression_case`, Python counts + * `try_statement` plus each `except_clause`. Cognitive weighting is Campbell's, + * identical to the generic walk (+1 plus the current nesting depth). */ +static bool elixir_is_branch_call(TSNode call, const char *source) { + if (ts_node_child_count(call) == 0) { + return false; + } + TSNode callee = ts_node_child(call, 0); + if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "identifier") != 0) { + return false; + } + return elixir_node_text_is(callee, source, "if") || + elixir_node_text_is(callee, source, "unless") || + elixir_node_text_is(callee, source, "with") || + elixir_node_text_is(callee, source, "for") || + elixir_node_text_is(callee, source, "case") || + elixir_node_text_is(callee, source, "cond") || + elixir_node_text_is(callee, source, "try") || + elixir_node_text_is(callee, source, "receive"); +} + +/* A comprehension (`for x <- xs, do: ...`) is Elixir's only real loop node. + * Enum.map/reduce iterate too, but they are plain function calls; counting them + * would make loop_depth meaningless, so they are left out. */ +static bool elixir_is_loop_call(TSNode call, const char *source) { + if (ts_node_child_count(call) == 0) { + return false; + } + TSNode callee = ts_node_child(call, 0); + return !ts_node_is_null(callee) && strcmp(ts_node_type(callee), "identifier") == 0 && + elixir_node_text_is(callee, source, "for"); +} + +static bool elixir_is_branch_operator(TSNode node, const char *source) { + TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator")); + return elixir_node_text_is(op, source, "&&") || elixir_node_text_is(op, source, "||") || + elixir_node_text_is(op, source, "and") || elixir_node_text_is(op, source, "or") || + elixir_node_text_is(op, source, "when"); +} + +static void elixir_compute_complexity(TSNode node, const char *source, cbm_complexity_t *out) { + enum { ELIXIR_CX_STACK_CAP = 4096 }; + struct { + TSNode node; + int bdepth; + int ldepth; + int adepth; + } stack[ELIXIR_CX_STACK_CAP]; + memset(out, 0, sizeof(*out)); + int top = 0; + stack[top].node = node; + stack[top].bdepth = 0; + stack[top].ldepth = 0; + stack[top].adepth = 0; + top++; + while (top > 0) { + int cur = --top; + TSNode n = stack[cur].node; + int bdepth = stack[cur].bdepth; + int ldepth = stack[cur].ldepth; + int adepth = stack[cur].adepth; + const char *kind = ts_node_type(n); + bool is_branch = false; + if (strcmp(kind, "stab_clause") == 0) { + is_branch = true; + } else if (strcmp(kind, "call") == 0) { + is_branch = elixir_is_branch_call(n, source); + } else if (strcmp(kind, "binary_operator") == 0) { + is_branch = elixir_is_branch_operator(n, source); + } + int child_b = bdepth; + int child_l = ldepth; + /* `conn.assigns.user` nests as dot(dot(conn)), so consecutive `dot` + * nodes deepen the chain and anything else resets it. */ + int child_a = 0; + if (strcmp(kind, "dot") == 0) { + child_a = adepth + 1; + if (child_a > out->max_access_depth) { + out->max_access_depth = child_a; + } + } + if (is_branch) { + out->cyclomatic++; + out->cognitive += 1 + bdepth; + child_b = bdepth + 1; + } + if (strcmp(kind, "call") == 0 && elixir_is_loop_call(n, source)) { + out->loop_count++; + int d = ldepth + 1; + if (d > out->loop_depth) { + out->loop_depth = d; + } + child_l = d; + } + uint32_t nc = ts_node_child_count(n); + for (int i = (int)nc - 1; i >= 0 && top < ELIXIR_CX_STACK_CAP; i--) { + stack[top].node = ts_node_child(n, (uint32_t)i); + stack[top].bdepth = child_b; + stack[top].ldepth = child_l; + stack[top].adepth = child_a; + top++; + } + } +} + static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro) { CBMArena *a = ctx->arena; TSNode args = elixir_call_args(node); @@ -5017,6 +5145,21 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char 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.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET); + cbm_complexity_t cx; + elixir_compute_complexity(node, ctx->source, &cx); + def.complexity = cx.cyclomatic; + def.cognitive = cx.cognitive; + def.loop_count = cx.loop_count; + def.loop_depth = cx.loop_depth; + def.max_access_depth = cx.max_access_depth; + /* Fingerprint off the body, not the whole def call: including the head + * would make every clause of a name look alike. Elixir was the only + * language emitting Functions with no fingerprint at all, so it never + * appeared in similarity or duplicate results. Runs after `lines` — the + * AST profile reads it. */ + TSNode fp_body = cbm_find_child_by_kind(node, "do_block"); + compute_fingerprint(ctx, &def, ts_node_is_null(fp_body) ? node : fp_body); cbm_defs_push(&ctx->result->defs, a, def); } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 447e7c104c..ea883bd29b 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1005,6 +1005,45 @@ TEST(elixir_function) { PASS(); } +/* Elixir control flow is `call` nodes and `stab_clause` arms, not branch node + * TYPES, so the generic complexity walk scored every Elixir function 0 and the + * fingerprint was never computed at all — no similarity or duplicate detection. */ +TEST(elixir_complexity_and_fingerprint) { + CBMFileResult *r = extract("defmodule Cx do\n" + " def classify(x) do\n" + " case x do\n" + " 0 -> :zero\n" + " n when n > 0 -> :pos\n" + " _ -> :neg\n" + " end\n" + " end\n" + " def plain(a), do: a\n" + "end\n", + CBM_LANG_ELIXIR, "t", "cx.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + CBMDefinition *cls = NULL; + CBMDefinition *plain = NULL; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].name, "classify") == 0) + cls = &r->defs.items[i]; + if (strcmp(r->defs.items[i].name, "plain") == 0) + plain = &r->defs.items[i]; + } + ASSERT_NOT_NULL(cls); + /* The `case` itself + its 3 arms + the second arm's own `when` guard. */ + ASSERT_EQ(5, cls->complexity); + ASSERT(cls->cognitive > 0); + ASSERT(cls->lines > 0); + /* A straight-line body has no decisions but still gets a line count — a + * language must not become nonzero everywhere just by being measured. */ + ASSERT_NOT_NULL(plain); + ASSERT_EQ(0, plain->complexity); + ASSERT(plain->lines > 0); + cbm_free_result(r); + PASS(); +} + /* --- Haskell --- */ TEST(haskell_function) { CBMFileResult *r = extract("add :: Int -> Int -> Int\nadd x y = x + y\n\nmultiply :: Int -> " @@ -5432,6 +5471,7 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); + RUN_TEST(elixir_complexity_and_fingerprint); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); RUN_TEST(erlang_function); From 5ca234c2b24c27a8a484e744cc200b93e01f130b Mon Sep 17 00:00:00 2001 From: LeoLan Digital Date: Tue, 25 Aug 2026 15:15:56 +0200 Subject: [PATCH 02/97] fix: persist daemon watches across client disconnects Signed-off-by: LeoLan Digital Signed-off-by: LeoLan Digital Signed-off-by: LeoLan Digital --- src/daemon/application.c | 65 ++++++++++++++------ tests/test_daemon_application.c | 102 ++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 17 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 7e4a35be7e..ce0cfdaf85 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -149,11 +149,11 @@ struct cbm_daemon_application_job { cbm_daemon_application_job_t *next; }; -/* A watcher-triggered physical job is owned by the exact live sessions that - * currently subscribe to its project/root watch. The callback waiting for the - * job is only a storage waiter; it is deliberately not an ownership - * subscription, so the worker is cancelled when the last matching session - * disconnects even while unrelated daemon sessions remain alive. */ +/* A watcher-triggered physical job is normally owned by the exact live + * sessions subscribed to its project/root watch. In permanent daemon mode the + * watcher itself additionally owns the physical job so background indexing can + * continue while no client session is connected. The callback remains a + * storage waiter in both modes and releases daemon ownership on completion. */ struct cbm_daemon_application_watch_job_subscription { cbm_daemon_application_session_t *session; cbm_daemon_application_job_t *job; @@ -424,7 +424,19 @@ static void application_release_session_watch_locked(cbm_daemon_application_sess if (watch->subscribers > 0) { watch->subscribers--; } - if (watch->subscribers == 0) { + + /* + * A permanent daemon owns the physical project watch independently of + * individual client sessions. Session disconnect releases only that + * session's logical subscription; the watcher remains available for + * background change detection while the daemon generation is alive. + * + * Non-permanent applications retain the historical session-scoped + * lifecycle and remove the physical watch after the final subscriber. + * Project deletion/pruning and daemon shutdown remove persistent watches + * through their dedicated lifecycle paths. + */ + if (!session->application->permanent && watch->subscribers == 0) { application_remove_watch_locked(session->application, watch); } } @@ -3375,10 +3387,13 @@ static int application_background_index(cbm_daemon_application_t *application, cbm_mutex_lock(&application->mutex); cbm_daemon_application_watch_t *watch = require_live_watch ? application_find_watch_locked(application, project_name) : NULL; - bool watch_live = !require_live_watch || - (watch && watch->subscribers > 0 && strcmp(watch->root, canonical_root) == 0); + bool watch_live = + !require_live_watch || + (watch && strcmp(watch->root, canonical_root) == 0 && + (watch->subscribers > 0 || application->permanent)); size_t watch_owner_count = 0; bool watch_subscriptions_ok = true; + bool watcher_owns_job = false; cbm_daemon_application_job_t *job = watch_live ? application_job_subscribe_locked(application, project_key, canonical_root, args, &subscribe_status) @@ -3386,16 +3401,28 @@ static int application_background_index(cbm_daemon_application_t *application, if (job && require_live_watch) { watch_subscriptions_ok = application_watch_job_subscribe_sessions_locked( application, watch, job, &watch_owner_count); - if (watch_subscriptions_ok && watch_owner_count > 0) { - job->watcher_waiters++; - } else if (!watch_subscriptions_ok) { + + if (!watch_subscriptions_ok) { subscribe_status = APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED; - } - /* application_job_subscribe_locked() lends the caller one ordinary - * subscriber. A watcher callback is only a storage waiter: exact live - * session subscriptions above own the physical work. */ - application_job_unsubscribe_locked(job); - if (!watch_subscriptions_ok || watch_owner_count == 0) { + application_job_unsubscribe_locked(job); + job = NULL; + } else if (application->permanent) { + /* + * The ordinary subscription acquired above is retained as daemon + * ownership. This allows the physical watcher to run indexing even + * while no client session currently owns the project watch. + */ + watcher_owns_job = true; + job->watcher_waiters++; + } else if (watch_owner_count > 0) { + /* + * Historical non-permanent behavior: exact live sessions own the + * physical work; the watcher callback itself is only a waiter. + */ + job->watcher_waiters++; + application_job_unsubscribe_locked(job); + } else { + application_job_unsubscribe_locked(job); job = NULL; } } @@ -3428,6 +3455,10 @@ static int application_background_index(cbm_daemon_application_t *application, if (job->watcher_waiters > 0) { job->watcher_waiters--; } + if (watcher_owns_job) { + application_job_unsubscribe_locked(job); + watcher_owns_job = false; + } } else { application_job_unsubscribe_locked(job); } diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index c639ace2f7..b0b55e4f6b 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -3621,6 +3621,107 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) { PASS(); } + +TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 47); + + if (fixture_ready) { + cbm_daemon_application_set_permanent(fixture.application, true); + } + + int watch_before_disconnect = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + if (fixture.session) { + fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session); + } + + int watch_after_cancel = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + + int watch_after_close = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + /* + * No client session remains at this point. A permanent daemon-owned + * physical watch must nevertheless be allowed to launch its background + * indexing job. + */ + app_watcher_index_thread_t request = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = false, + .result = -1, + }; + atomic_init(&request.ready, false); + atomic_init(&request.proceed, true); + atomic_init(&request.done, false); + + cbm_thread_t thread; + bool thread_started = + fixture_ready && + watch_after_close == 1 && + cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; + + bool worker_started = + thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); + + int watch_during_job = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + /* + * The fake worker deliberately waits until the test releases it. + * Let the daemon-owned watcher job finish normally. + */ + if (worker_started) { + atomic_store(&fixture.fake.allow_completion, true); + } + + bool request_done = + thread_started && app_wait_for_atomic_bool(&request.done, true); + + bool thread_joined = + request_done && cbm_thread_join(&thread) == 0; + + int watch_after_job = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + int destroys_before_cleanup = + fixture_ready ? atomic_load(&fixture.fake.destroys) : -1; + + bool cleaned = app_watch_race_fixture_finish(&fixture); + + ASSERT_TRUE(fixture_ready); + ASSERT_EQ(watch_before_disconnect, 1); + + /* Permanent mode: disconnect must not unwatch the project. */ + ASSERT_EQ(watch_after_cancel, 1); + ASSERT_EQ(watch_after_close, 1); + + /* No session exists, yet the watcher still owns background indexing. */ + ASSERT_TRUE(thread_started); + ASSERT_TRUE(worker_started); + ASSERT_EQ(watch_during_job, 1); + + ASSERT_TRUE(request_done); + ASSERT_TRUE(thread_joined); + ASSERT_EQ(request.result, 0); + ASSERT_EQ(destroys_before_cleanup, 1); + + /* Completing the background job must not consume the persistent watch. */ + ASSERT_EQ(watch_after_job, 1); + + ASSERT_TRUE(cleaned); + PASS(); +} + TEST(daemon_application_late_watcher_session_owns_active_watcher_job) { app_watch_race_fixture_t fixture; bool fixture_ready = app_watch_race_fixture_init(&fixture, 45); @@ -5116,6 +5217,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission); RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job); RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners); + RUN_TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session); RUN_TEST(daemon_application_late_watcher_session_owns_active_watcher_job); RUN_TEST(daemon_application_serializes_adr_mutation_with_index_job); RUN_TEST(daemon_application_reserved_mutation_delays_worker_start); From 5e328814fc99ffe588daae4f0f1e8cf5066e084b Mon Sep 17 00:00:00 2001 From: Jumaga2015 Date: Tue, 1 Sep 2026 21:00:26 +0200 Subject: [PATCH 03/97] fix(store,ui): export paired free functions for FFI callers cbm_store_fetch_call_edges, cbm_leiden/cbm_louvain, and cbm_layout_to_json document caller-owned memory ("caller frees both" / "the caller frees it" / "Caller must free()") but exported no matching free function, unlike the rest of the API (cbm_store_find_nodes -> cbm_store_free_nodes, cbm_layout_compute -> cbm_layout_free, etc.). From C a bare free() works via static linking, but a caller across an FFI boundary (e.g. C# P/Invoke) has no way to reach the DLL's internal free() unless it is exported by name. Adds three paired free functions following the existing pattern: - cbm_store_free_call_edges(src, tgt) - cbm_leiden_free(result) - cbm_layout_free_json(json) Updates the one internal production caller (mcp.c SCC analysis) to use cbm_store_free_call_edges instead of a bare free(), and the existing unit tests for cbm_leiden/cbm_louvain/cbm_layout_to_json to use the new paired frees. Adds fetch_call_edges_basic, fetch_call_edges_truncated, and fetch_call_edges_empty_project to test_store_arch.c, covering cbm_store_fetch_call_edges / cbm_store_free_call_edges, which had no prior test coverage. Fixes #1762 Signed-off-by: Jumaga2015 --- src/mcp/mcp.c | 6 +-- src/store/store.c | 9 +++++ src/store/store.h | 6 +++ src/ui/layout3d.c | 4 ++ src/ui/layout3d.h | 3 ++ tests/test_store_arch.c | 81 +++++++++++++++++++++++++++++++++++++---- tests/test_ui.c | 4 +- 7 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index e908211b84..1d154bb28b 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -5545,12 +5545,10 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t *scanned_edges = ecount; scc_graph_t g; if (!scc_build(src, tgt, ecount, &g)) { - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); return CBM_STORE_OK; /* no edges = no cycles, not an error */ } - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); int *comp = malloc((size_t)g.nverts * sizeof(int)); if (!comp) { diff --git a/src/store/store.c b/src/store/store.c index 03e42b0ed0..e36c93b1be 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -3142,6 +3142,11 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge return CBM_STORE_OK; } +void cbm_store_free_call_edges(int64_t *src, int64_t *tgt) { + free(src); + free(tgt); +} + int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count) { bind_id_t b = {source_id}; @@ -8234,6 +8239,10 @@ int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t * return cbm_leiden(nodes, node_count, edges, edge_count, 1.0, out, out_count); } +void cbm_leiden_free(cbm_louvain_result_t *result) { + free(result); +} + /* ── Architecture: community clusters via Leiden ───────────────── */ enum { diff --git a/src/store/store.h b/src/store/store.h index 165356c31e..7bd7e0bbe7 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -574,6 +574,9 @@ int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int cou int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges, int64_t **out_src, int64_t **out_tgt, int *count, bool *truncated); +/* Free the (source_id, target_id) arrays returned by cbm_store_fetch_call_edges. */ +void cbm_store_free_call_edges(int64_t *src, int64_t *tgt); + /* Find edges by source node. */ int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count); @@ -996,6 +999,9 @@ int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *e int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, int edge_count, cbm_louvain_result_t **out, int *out_count); +/* Free the result array returned by cbm_leiden / cbm_louvain. */ +void cbm_leiden_free(cbm_louvain_result_t *result); + /* ── Memory management helpers ──────────────────────────────────── */ /* Free heap-allocated strings in a stack-allocated node (does NOT free the node itself). */ diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 3176da2ffb..8a5e32aea4 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -867,3 +867,7 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { } return json; } + +void cbm_layout_free_json(char *json) { + free(json); +} diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h index 20303bf644..758f0be74e 100644 --- a/src/ui/layout3d.h +++ b/src/ui/layout3d.h @@ -71,4 +71,7 @@ void cbm_layout_free(cbm_layout_result_t *result); /* Serialize layout result to JSON string. Caller must free(). */ char *cbm_layout_to_json(const cbm_layout_result_t *result); +/* Free the JSON string returned by cbm_layout_to_json. */ +void cbm_layout_free_json(char *json); + #endif /* CBM_UI_LAYOUT3D_H */ diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 4e9f590683..aeb8b3d053 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -1189,6 +1189,68 @@ TEST(adr_validate_keys_empty) { PASS(); } +/* ── cbm_store_fetch_call_edges / cbm_store_free_call_edges tests ─ */ + +TEST(fetch_call_edges_basic) { + cbm_store_t *s = setup_arch_test_store(); + ASSERT_NOT_NULL(s); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + /* setup_arch_test_store() wires 5 CALLS edges among Function nodes. */ + ASSERT_EQ(count, 5); + ASSERT_FALSE(truncated); + ASSERT_NOT_NULL(src); + ASSERT_NOT_NULL(tgt); + + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +TEST(fetch_call_edges_truncated) { + cbm_store_t *s = setup_arch_test_store(); + ASSERT_NOT_NULL(s); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = false; + /* Only 5 CALLS edges exist; capping at 2 must set truncated. */ + ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 2, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + ASSERT_TRUE(truncated); + + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +TEST(fetch_call_edges_empty_project) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "empty", "/tmp/empty"); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "empty", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + ASSERT_FALSE(truncated); + + /* cbm_store_free_call_edges(NULL, NULL) must not crash. */ + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + /* ── Louvain tests ──────────────────────────────────────────────── */ TEST(louvain_basic) { @@ -1214,7 +1276,7 @@ TEST(louvain_basic) { /* Triangle and pair different */ ASSERT_TRUE(comm[1] != comm[4]); - free(result); + cbm_leiden_free(result); PASS(); } @@ -1223,7 +1285,7 @@ TEST(louvain_empty) { int count = 0; ASSERT_EQ(cbm_louvain(NULL, 0, NULL, 0, &result, &count), CBM_STORE_OK); ASSERT_EQ(count, 0); - free(result); + cbm_leiden_free(result); PASS(); } @@ -1234,7 +1296,7 @@ TEST(louvain_single_node) { ASSERT_EQ(cbm_louvain(nodes, 1, NULL, 0, &result, &count), CBM_STORE_OK); ASSERT_EQ(count, 1); ASSERT_EQ(result[0].node_id, 42); - free(result); + cbm_leiden_free(result); PASS(); } @@ -1297,7 +1359,7 @@ TEST(louvain_converges) { } ASSERT_TRUE(same_count >= 8); - free(result); + cbm_leiden_free(result); PASS(); } @@ -1431,7 +1493,7 @@ TEST(leiden_multilevel_collapses_noise) { } ASSERT_TRUE(same >= SZ - 1); } - free(result); + cbm_leiden_free(result); PASS(); } @@ -1463,8 +1525,8 @@ TEST(leiden_resolution_controls_granularity) { ASSERT_TRUE(n_hi > n_lo); ASSERT_TRUE(leiden_all_communities_connected(lo, N, edges, ne)); ASSERT_TRUE(leiden_all_communities_connected(hi, N, edges, ne)); - free(lo); - free(hi); + cbm_leiden_free(lo); + cbm_leiden_free(hi); PASS(); } @@ -1727,6 +1789,11 @@ SUITE(store_arch) { RUN_TEST(adr_splice_refuses_unterminated_fence); RUN_TEST(adr_validate_keys_empty); + /* Call-edge fetch / free */ + RUN_TEST(fetch_call_edges_basic); + RUN_TEST(fetch_call_edges_truncated); + RUN_TEST(fetch_call_edges_empty_project); + /* Louvain */ RUN_TEST(louvain_basic); RUN_TEST(louvain_empty); diff --git a/tests/test_ui.c b/tests/test_ui.c index a403dbbbec..c1975a4cae 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -548,7 +548,7 @@ TEST(layout_to_json) { ASSERT(strstr(json, "\"hello\"") != NULL); ASSERT(strstr(json, "\"Function\"") != NULL); - free(json); + cbm_layout_free_json(json); cbm_layout_free(r); cbm_store_close(store); PASS(); @@ -719,7 +719,7 @@ TEST(layout_dead_code_classification) { ASSERT_NOT_NULL(json); ASSERT(strstr(json, "\"status\":\"dead\"") != NULL); ASSERT(strstr(json, "\"in_calls\":2") != NULL); - free(json); + cbm_layout_free_json(json); cbm_layout_free(r); cbm_store_close(store); From 651ae3c2af1a22ec61a498b39350785aee480973 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Fri, 28 Aug 2026 17:26:26 +0200 Subject: [PATCH 04/97] fix(pipeline): suppress weak short-name matches for Go selector calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go selector call x.foo() whose receiver the Go LSP cannot type falls through to the generic registry resolver, which binds it by bare short name to an arbitrary same-named project symbol. Stdlib calls are the worst case: f.Close() on an *os.File gets a CALLS edge to whatever project Close wins candidate ranking (measured on a real Go repo: confidence 0.11, 15 candidates; suffix_match + unique_name were 36% of all CALLS edges, and one 14-line stdlib-only function got 3 out of 3 false outbound edges). Extend the TS/JS receiver-aware guard (#592/#606) to Go: - extract_calls.c: flag Go call_expression with a selector_expression callee as is_method, mirroring the TS/JS member_expression flag. - registry.c: add cbm_go_suppress_weak_method_match. Unlike the TS/JS drop-list, field_type_hint is KEPT (Go struct fields carry declared types, so the hint is receiver-aware — lrp_go_s8_field_type_hint), and unique_name is dropped only when its confidence carries the import-unreachability penalty (the stdlib-hijack shape); an unpenalized lone candidate inside the caller's import closure never enters the field-type-hint upgrade and must survive. - pass_calls.c / pass_parallel.c: feed the Go gate next to the TS/JS one; the drop still defers to the emit path so service/route/HTTP edges stay main-identical. Reproduce-first: pipeline_go_receiver_suppresses_weak_method_edge is RED without the extractor flag (the f.Close -> project Close edge exists) and GREEN with it; typed same-package calls, bare local calls and import-qualified cross-package calls still resolve. The old extraction contract test used Go as the flag-exempt language — Python takes that role, and extract_go_selector_call_flags_is_method pins the new behavior. Signed-off-by: Ilya Brykau --- internal/cbm/extract_calls.c | 18 +++++++ src/pipeline/pass_calls.c | 12 ++++- src/pipeline/pass_parallel.c | 8 ++- src/pipeline/pipeline.h | 9 ++++ src/pipeline/registry.c | 27 +++++++++++ tests/test_extraction.c | 39 +++++++++++++-- tests/test_pipeline.c | 94 ++++++++++++++++++++++++++++++++++++ tests/test_registry.c | 42 ++++++++++++++++ 8 files changed, 242 insertions(+), 7 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 6bb4a32bce..b7661d5e6d 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -3619,6 +3619,24 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML } } } + // Go receiver-aware guard (same direction as the TS/JS flag above). + // Flag a selector call x.foo(). The Go AST cannot separate a method + // call on a value from a package-qualified call — but every selector + // call the Go LSP or the import/qualified registry strategies CAN + // place never reaches the weak short-name guards, so the flag only + // bites on unresolvable receivers (`f.Close()` on an os.File, + // `sha256.New()` behind an unindexed import), where a project-wide + // short-name match fabricates an edge to an unrelated project + // symbol sharing the name. Bare calls (helper()) keep + // is_method=false and resolve same-module/import paths as before. + if (ctx->language == CBM_LANG_GO && + strcmp(ts_node_type(node), "call_expression") == 0) { + TSNode gofn = ts_node_child_by_field_name(node, TS_FIELD("function")); + if (!ts_node_is_null(gofn) && + strcmp(ts_node_type(gofn), "selector_expression") == 0) { + call.is_method = true; + } + } TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); // ObjectScript stores args under oref_method/method_args, not the diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index b25e9f592b..db6a86178f 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -623,12 +623,20 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, * language gated on only one resolver produces an edge on the sequential * path and not the parallel one (or vice versa), breaking MT determinism. * ArkTS belongs to the JS/TS family here (#1842); dropping it would - * reintroduce the #592/#606 false-edge class for .ets files. */ + * reintroduce the #592/#606 false-edge class for .ets files. + * + * Go (#1906) rides the same deferred-drop plumbing through its OWN + * predicate: its drop-list differs (field_type_hint is receiver-aware for + * Go, and unique_name drops only when import-unreachability-penalized), so + * it composes via cbm_go_suppress_weak_method_match instead of widening + * the shared gate. Same lockstep rule: mirror pass_parallel.c. */ bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; bool drop_plain_call = - cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy); + cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || + cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy, + res.confidence); /* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g. * `requests.get("/api/orders/{id}")`) resolve to a QN containing the library diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 1ce7669276..0a43a3f5e5 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2488,12 +2488,16 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * #606 direction. * * This language set MUST match the one in pass_calls.c exactly — see the - * note there. ArkTS belongs to the JS/TS family (#1842). */ + * note there. ArkTS belongs to the JS/TS family (#1842). Go (#1906) + * composes via its own predicate (different drop-list — see + * cbm_go_suppress_weak_method_match), mirrored in pass_calls.c. */ bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; bool drop_plain_call = - cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy); + cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || + cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy, + res.confidence); /* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the * service signal lives in the callee_name. The registry can mis-resolve diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index d174bb6d68..6122f450d4 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -280,6 +280,15 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c * Pure; unit-tested in test_registry.c. */ bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy); +/* Go analog of the TS/JS guard, same failure class: a selector call whose + * receiver the Go LSP could not type must not be bound by a receiver-blind + * short-name strategy. Drops suffix_match / fuzzy always, and unique_name only + * when its confidence is import-unreachability-penalized (the stdlib/vendor + * hijack shape). field_type_hint is deliberately NOT dropped for Go — struct + * fields carry declared types, so the hint is receiver-aware there. */ +bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, + double confidence); + /* #725: drop a suffix_match CALLS edge when the caller language and the * target file's language disagree. unique_name (candidates == 1) is #1572 * and is left alone; same_module / import_map / lsp_* are kept. JS/TS/TSX diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index eefdb75968..10ee864bd8 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -464,6 +464,33 @@ bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *st strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0; } +bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, + double confidence) { + if (!is_go || !is_method || !strategy || !strategy[0]) { + return false; + } + /* Go analog of the TS/JS guard above, same failure class: a selector call + * whose receiver the Go LSP could not type reaches the registry and a bare + * short-name strategy binds it to an arbitrary same-named project symbol + * (`f.Close()` on an os.File -> a project `Close`, suffix_match over 15 + * candidates). Unlike the TS/JS list, field_type_hint is KEPT: a Go struct + * field carries a declared type, so the parallel resolver's field-type + * hint is receiver-aware for Go (lrp_go_s8_field_type_hint), not a + * heuristic. */ + if (strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "fuzzy") == 0) { + return true; + } + /* unique_name is dropped only when PENALIZED: resolve_name_lookup scales + * CONF_UNIQUE_NAME by DEFAULT_CONFIDENCE exactly when the lone candidate + * is not reachable through the caller's imports — the stdlib/vendor + * hijack shape (`io.Copy` -> a project `Copy`). An unpenalized + * unique_name target sits inside the caller's import closure (or the + * file has no imports, e.g. a same-package call) and must be kept — + * dropping it kills genuinely-typed lone-candidate calls that never + * enter the field-type-hint upgrade (candidate_count == 1). */ + return strcmp(strategy, "unique_name") == 0 && confidence < CONF_UNIQUE_NAME; +} + static bool js_ts_family(CBMLanguage lang) { return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5b8d16f61a..6f7d0c0854 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4743,9 +4743,14 @@ TEST(extract_perl_method_call_flags_is_method) { /* Languages OUTSIDE the is_method flag set (only Perl and TS/JS/TSX set it) must * be unaffected: a Go method call never sets is_method. */ TEST(extract_flag_exempt_method_call_not_flagged_is_method) { - CBMFileResult *r = extract("package m\n" - "func run(o Obj) { o.Commit(); helper() }\n", - CBM_LANG_GO, "t", "x.go"); + /* Rust is flag-exempt: only Perl, Python, TS/JS and Go set is_method. + * Guards the blast radius of the receiver-aware flags for every other + * language. */ + CBMFileResult *r = extract("fn run(o: Obj) {\n" + " o.commit();\n" + " helper();\n" + "}\n", + CBM_LANG_RUST, "t", "x.rs"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); for (int i = 0; i < r->calls.count; i++) { @@ -4824,6 +4829,33 @@ TEST(extract_python_member_call_flags_is_method) { PASS(); } +TEST(extract_go_selector_call_flags_is_method) { + /* Go selector calls are flagged so the weak-match guard can fire when the + * Go LSP cannot type the receiver; bare calls stay unflagged. */ + CBMFileResult *r = extract("package m\n" + "func run(o Obj) { o.Commit(); helper() }\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + bool saw_selector = false; + bool saw_bare = false; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + if (c->callee_name && strstr(c->callee_name, "Commit") != NULL) { + ASSERT_TRUE(c->is_method); + saw_selector = true; + } + if (c->callee_name && strcmp(c->callee_name, "helper") == 0) { + ASSERT_FALSE(c->is_method); + saw_bare = true; + } + } + ASSERT_TRUE(saw_selector); + ASSERT_TRUE(saw_bare); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -6503,6 +6535,7 @@ SUITE(extraction) { RUN_TEST(extract_perl_method_call_flags_is_method); RUN_TEST(extract_flag_exempt_method_call_not_flagged_is_method); RUN_TEST(extract_python_member_call_flags_is_method); + RUN_TEST(extract_go_selector_call_flags_is_method); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d62a5572f5..2c7f457321 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4693,6 +4693,99 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_receiver_suppresses_weak_method_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* go.mod makes project imports resolvable — real Go repos always have one, + * and import reachability (the unique_name penalty) depends on it. */ + write_temp_file(tmp, "go.mod", "module example.com/myapp\n\ngo 1.22\n"); + /* The lone project symbol named "Close" — a real method. */ + write_temp_file(tmp, "storage/storage.go", + "package storage\n" + "\n" + "type Storage struct{ open bool }\n" + "\n" + "func NewStorage() *Storage { return &Storage{open: true} }\n" + "\n" + "func (s *Storage) Close() {\n" + "\ts.open = false\n" + "}\n" + "\n" + "func Boot() {\n" + "\ts := NewStorage()\n" + "\ts.Close()\n" + "}\n"); + /* Cross-package control target: imported by hash.go, so the caller file + * has a non-empty import map (like any real Go file) and unreachable + * unique_name candidates get the import penalty. */ + write_temp_file(tmp, "util/util.go", + "package util\n" + "\n" + "func Tag() string { return \"t\" }\n"); + /* Stdlib receiver: `f.Close()` closes an *os.File, NOT the project method. + * The Go LSP cannot bind it to a project symbol → the registry would guess + * Close by short name (weak). This is the false edge to suppress — + * the exact shape that attached every file/rows/gzip Close in a real Go + * repo to one unrelated project method. */ + write_temp_file(tmp, "hash/hash.go", + "package hash\n" + "\n" + "import (\n" + "\t\"os\"\n" + "\n" + "\t\"example.com/myapp/util\"\n" + ")\n" + "\n" + "func FileLen(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 localHelper() int { return 1 }\n" + "\n" + "func CallsLocal() int { return localHelper() }\n" + "\n" + "func UsesUtil() string { return util.Tag() }\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_recv.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) The false edge is suppressed (reproduce-first: RED before the fix). */ + ASSERT_FALSE(cross_file_call_exists(s, project, "FileLen", "Close")); + /* (2) The same-package typed-receiver call survives (LSP / same_module — + * both outside the weak drop-list). */ + ASSERT_TRUE(cross_file_call_exists(s, project, "Boot", "Close")); + /* (3) The bare local call survives (is_method stays false for bare calls). */ + ASSERT_TRUE(cross_file_call_exists(s, project, "CallsLocal", "localHelper")); + /* (4) The import-qualified cross-package call survives (import-aware + * strategies are outside the drop-list). */ + ASSERT_TRUE(cross_file_call_exists(s, project, "UsesUtil", "Tag")); + + 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 @@ -13115,6 +13208,7 @@ SUITE(pipeline) { #endif RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); RUN_TEST(pipeline_go_bare_ref_never_binds_field); diff --git a/tests/test_registry.c b/tests/test_registry.c index 3e1d604af8..b58e98dded 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -901,6 +901,46 @@ TEST(dynamic_suppress_keeps_high_confidence_and_non_methods) { PASS(); } +TEST(go_suppress_drops_weak_selector_matches) { + /* Go selector call with an untyped receiver, landed via a receiver-blind + * short-name strategy → drop (same failure class as #592/#606). + * suffix_match/fuzzy drop at any confidence; unique_name drops only when + * import-unreachability-penalized (CONF_UNIQUE_NAME 0.75 * 0.5 = 0.375 — + * the `io.Copy` -> project `Copy` stdlib-hijack shape). */ + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "suffix_match", 0.9)); + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "suffix_match", 0.11)); + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "fuzzy", 0.9)); + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.375)); + PASS(); +} + +TEST(go_suppress_keeps_typed_and_import_aware_matches) { + /* Unpenalized unique_name = lone candidate inside the caller's import + * closure (or an import-free file, e.g. same-package) — a genuinely-typed + * lone-candidate call never enters the field-type-hint upgrade, so it must + * survive (lrp_go_s8_field_type_hint). */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.75)); + /* field_type_hint is receiver-aware for Go — struct fields carry declared + * types (lrp_go_s8_field_type_hint) — so it stays, unlike the TS/JS list. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "field_type_hint", 0.85)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "same_module", 0.9)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map", 0.95)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map_suffix", 0.9)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "qualified_suffix", 0.9)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "callee_suffix", 0.5)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "service_pattern", 0.5)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "lsp_strategy_cross_file", 0.92)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "lsp_direct", 0.95)); + /* A bare call (is_method=false) is a free-function call → never suppressed. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, false, "suffix_match", 0.11)); + /* Non-Go languages are never affected by this gate. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(false, true, "suffix_match", 0.11)); + /* No match (NULL/empty strategy) → nothing to suppress. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, NULL, 0.5)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "", 0.5)); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ /* Method call THROUGH an imported symbol that is itself an indexed node @@ -997,4 +1037,6 @@ SUITE(registry) { RUN_TEST(go_bare_ref_never_binds_field); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); + RUN_TEST(go_suppress_drops_weak_selector_matches); + RUN_TEST(go_suppress_keeps_typed_and_import_aware_matches); } From dbba7573016e17f20fe5afaafe5cc2cddd98744e Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sat, 29 Aug 2026 18:04:16 +0200 Subject: [PATCH 05/97] fix(extract): receiver-qualify Go method QNs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go method's QN was the flat package form (proj.pkg.method) — the receiver was ignored, so every same-name method in a package collided on one QN and the graph upsert kept exactly one node. Measured on a real Go repo: 20 Process/Name methods across 15 files kept 2 nodes; 9 different Task() methods fused into one chimera node carrying all nine bodies' call edges; 19 structs pointed DEFINES_METHOD at a single shared method node; a _test.go mock Close outranked the production Close in the dedupe tie-break. The upsert's own comment calls kind-disambiguated QNs 'the real cure'. Qualify the QN with the receiver type (proj.pkg.Recv.method), the same shape as Go interface members and the C++ out-of-line method path right below it in extract_func_def: - extract_defs.c: def.qualified_name = parent_class + name whenever the receiver type resolves; go_receiver_type_name becomes the shared cbm_go_receiver_type_name (exported via helpers.h) so both sides of the contract use one formula. - extract_unified.c (compute_func_qn): mirror branch for method_declaration, so method-body calls keep exact source attribution instead of degrading to File-node fallback (calls_find_source). - Consumers already agree: pxc_build_lsp_def passes the def QN and parent_class (receiver_type) verbatim into the Go LSP registries, and check_go_class_implements explicitly supports class-qualified method QNs (its path (b)). Side effect: resolve_same_module's exact module.name hash no longer matches concrete methods, which kills the conf-0.9 false edges where an interface-typed call bound to an unrelated same-package method. Fixes #1909 Signed-off-by: Ilya Brykau --- internal/cbm/extract_defs.c | 14 +++++++-- internal/cbm/extract_unified.c | 19 +++++++++++++ internal/cbm/helpers.h | 6 ++++ internal/cbm/lsp/go_lsp.c | 25 ++++++++++------ tests/test_extraction.c | 50 ++++++++++++++++++++++++++++++-- tests/test_parallel.c | 4 ++- tests/test_pipeline.c | 52 ++++++++++++++++++++++++++++++++++ 7 files changed, 156 insertions(+), 14 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2e359af67a..b298ec50ca 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3508,7 +3508,7 @@ static void set_def_complexity(CBMDefinition *def, TSNode body, const CBMLangSpe * Walks to the parameter_declaration's `type` field, unwrapping pointer_type * and generic_type, and returns the type_identifier text (e.g. "OrderService"). * Returns NULL if no type_identifier is found. */ -static char *go_receiver_type_name(CBMArena *a, TSNode recv, const char *source) { +char *cbm_go_receiver_type_name(CBMArena *a, TSNode recv, const char *source) { uint32_t nc = ts_node_child_count(recv); for (uint32_t i = 0; i < nc; i++) { TSNode child = ts_node_child(recv, i); @@ -3720,12 +3720,22 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * (and downstream Go IMPLEMENTS/OVERRIDE) link the method to its owning * struct/type node. The parent QN must match the type's node QN, which * is computed the same way (cbm_fqn_compute on the type name). */ - char *recv_type = go_receiver_type_name(a, recv, ctx->source); + char *recv_type = cbm_go_receiver_type_name(a, recv, ctx->source); if (recv_type && recv_type[0]) { /* Must match the Go type node QN (directory-based module) so the * DEFINES_METHOD edge links the method to its owning type. */ def.parent_class = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, recv_type, ctx->language); + /* Receiver-qualify the method QN (proj.pkg.Recv.method) — same + * shape as the C++ out-of-line path below and Go interface + * members. With the flat proj.pkg.method QN every same-name + * method in a package collided in the graph upsert: one body + * survived and the twins' call edges accreted onto it. The + * call-scope side (compute_func_qn in extract_unified.c) mirrors + * this formula, and go_lsp consumers read the def QN and + * parent_class (receiver_type) verbatim, so resolution joins + * stay exact. */ + def.qualified_name = cbm_arena_sprintf(a, "%s.%s", def.parent_class, name); } } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index a5e3142727..8502c37b81 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -899,6 +899,25 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan } } + /* Go method `func (s *Storage) Close() {...}`: the def extractor records + * this as Method "proj.pkg.Storage.Close" (receiver-qualified, mirroring + * the C++ out-of-line rule above). The call-scope QN must match — a bare + * "proj.pkg.Close" names a node that no longer exists, so every call + * inside the method body would fall back to File-node attribution + * (calls_find_source). Same ONE-formula contract as the def side: + * cbm_go_receiver_type_name + cbm_fqn_compute_source_lang. */ + if (ctx->language == CBM_LANG_GO && strcmp(ts_node_type(node), "method_declaration") == 0) { + TSNode recv = ts_node_child_by_field_name(node, TS_FIELD("receiver")); + if (!ts_node_is_null(recv)) { + char *recv_type = cbm_go_receiver_type_name(ctx->arena, recv, ctx->source); + if (recv_type && recv_type[0]) { + const char *type_qn = cbm_fqn_compute_source_lang( + ctx->arena, ctx->project, ctx->rel_path, recv_type, ctx->language); + return cbm_arena_sprintf(ctx->arena, "%s.%s", type_qn, name); + } + } + } + /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def * extractor bakes it into the def QN. Compose it identically here — otherwise * an in-body call sources to a QN one or more segments short of the def, and diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 4e8150b6f7..29a8583284 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -119,6 +119,12 @@ TSNode cbm_resolve_func_name(TSNode node, CBMLanguage lang); // def extractor — drift dropped the class qualifier from in-body calls (#554/#621). char *cbm_cpp_out_of_line_parent_class(CBMArena *a, TSNode node, const char *source); +/* Go: resolve a method_declaration's receiver parameter_list down to the bare + * receiver type_identifier (unwrapping pointer_type / generic_type). Shared by + * def extraction (extract_defs.c) and call-scope attribution + * (extract_unified.c) so the receiver-qualified method QN has ONE formula. */ +char *cbm_go_receiver_type_name(CBMArena *a, TSNode recv, const char *source); + // Find a child node by kind string. TSNode cbm_find_child_by_kind(TSNode parent, const char *kind); diff --git a/internal/cbm/lsp/go_lsp.c b/internal/cbm/lsp/go_lsp.c index 7efa273e1e..812026291d 100644 --- a/internal/cbm/lsp/go_lsp.c +++ b/internal/cbm/lsp/go_lsp.c @@ -1859,15 +1859,24 @@ static void process_function(GoLSPContext* ctx, TSNode func_node) { char* func_name = lsp_node_text(ctx, name_node); if (!func_name || !func_name[0]) return; - // Enclosing-function QN must be the BARE package.Func form (no receiver - // type segment). The textual call events (extract_unified.c) source calls - // as package_qn.func_name — methods included — and the defs pass creates - // the graph Method node under the same QN, so any other form breaks the - // caller-QN join in cbm_pipeline_find_lsp_resolution and the LSP-resolved - // call silently falls back to the registry short-name resolver. The - // receiver type still reaches the registry via the def's parent_class / - // method->receiver_type; it just does not appear in the caller QN. + // Enclosing-function QN must be EXACTLY the QN the textual call events + // (extract_unified.c compute_func_qn) and the defs pass produce, or the + // caller-QN join in cbm_pipeline_find_lsp_resolution breaks and the + // LSP-resolved call silently falls back to the registry short-name + // resolver. Since #1909 receiver-qualified Go method QNs, that shared + // form is package_qn.ReceiverType.method for methods (the ONE-formula + // contract: cbm_go_receiver_type_name) and package_qn.func for functions. ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name); + if (strcmp(ts_node_type(func_node), "method_declaration") == 0) { + TSNode recv = ts_node_child_by_field_name(func_node, "receiver", 8); + if (!ts_node_is_null(recv)) { + char *recv_type = cbm_go_receiver_type_name(ctx->arena, recv, ctx->source); + if (recv_type && recv_type[0]) { + ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s.%s", ctx->package_qn, + recv_type, func_name); + } + } + } // Push function scope CBMScope* saved_scope = ctx->current_scope; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 6f7d0c0854..8f5fbf2d2e 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4254,12 +4254,12 @@ TEST(extract_go_no_filename_in_module_qn) { ASSERT_NOT_NULL(conn); ASSERT_STR_EQ(conn->qualified_name, "proj.myapp.db.Conn"); - /* Go method nodes keep a FLAT QN (module + name) with a separate - * parent_class link to the receiver type — the QN must carry the + /* Go method nodes carry a receiver-qualified QN (module + receiver type + + * name) plus the parent_class link — the QN must carry the * directory-based module and NOT the `.conn.` filename segment. */ const CBMDefinition *query = find_def_by_name(r, "Query"); ASSERT_NOT_NULL(query); - ASSERT_STR_EQ(query->qualified_name, "proj.myapp.db.Query"); + ASSERT_STR_EQ(query->qualified_name, "proj.myapp.db.Conn.Query"); ASSERT_EQ(strstr(query->qualified_name, ".conn."), NULL); /* The method's parent_class must match the type node QN (for DEFINES_METHOD). */ ASSERT_NOT_NULL(query->parent_class); @@ -4856,6 +4856,49 @@ TEST(extract_go_selector_call_flags_is_method) { PASS(); } +TEST(extract_go_method_receiver_qualified_qn) { + /* A Go method QN carries the receiver type (proj.pkg.Recv.method), same + * shape as C++ out-of-line methods and Go interface members — so two + * same-name methods on different receivers no longer collide in the + * graph upsert. Free functions keep the flat package QN. */ + CBMFileResult *r = extract("package m\n" + "type Storage struct{}\n" + "type Cache struct{}\n" + "func (s *Storage) Close() {}\n" + "func (c Cache) Close() {}\n" + "func Shutdown() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + bool saw_storage = false; + bool saw_cache = false; + bool saw_free = false; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (!d->name || !d->qualified_name) { + continue; + } + if (strcmp(d->name, "Close") == 0 && strcmp(d->qualified_name, "t.Storage.Close") == 0) { + ASSERT_STR_EQ(d->label, "Method"); + ASSERT_STR_EQ(d->parent_class, "t.Storage"); + saw_storage = true; + } + if (strcmp(d->name, "Close") == 0 && strcmp(d->qualified_name, "t.Cache.Close") == 0) { + ASSERT_STR_EQ(d->parent_class, "t.Cache"); + saw_cache = true; + } + if (strcmp(d->name, "Shutdown") == 0) { + ASSERT_STR_EQ(d->qualified_name, "t.Shutdown"); + saw_free = true; + } + } + ASSERT_TRUE(saw_storage); + ASSERT_TRUE(saw_cache); + ASSERT_TRUE(saw_free); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -6536,6 +6579,7 @@ SUITE(extraction) { RUN_TEST(extract_flag_exempt_method_call_not_flagged_is_method); RUN_TEST(extract_python_member_call_flags_is_method); RUN_TEST(extract_go_selector_call_flags_is_method); + RUN_TEST(extract_go_method_receiver_qualified_qn); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 631446f9c4..d3cd08417a 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3379,7 +3379,9 @@ TEST(parallel_go_cross_package_field_chain_resolves) { cbm_gbuf_t *gbuf = run_go_field_chain_sequential("go_field_fold", tmpdir, files, 2); ASSERT_NOT_NULL(gbuf); - const cbm_gbuf_edge_t *edge = find_call_edge_to_target_fragment(gbuf, "handler.PlaceOrder", ".service.PlaceOrder"); + /* Since #1909, Go method QNs are receiver-qualified on both endpoints. */ + const cbm_gbuf_edge_t *edge = find_call_edge_to_target_fragment( + gbuf, "OrderHandler.PlaceOrder", ".service.OrderService.PlaceOrder"); const bool found = edge != NULL; const bool dispatch = edge && edge->properties_json && diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2c7f457321..96eac87148 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4693,6 +4693,57 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_method_caller_keeps_lsp_join) { + /* #1909: receiver-qualifying method QNs moves the def/textual-call QN to + * package.Type.method — go_lsp's enclosing-function QN must move with it + * (the ONE-formula contract), or every LSP resolution sourced from inside + * a method body loses its caller join and the edge dies. The callee name + * collides across receivers so no short-name fallback can mask the loss. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_mcall_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "go.mod", "module example.com/fxmcall\n\ngo 1.22\n"); + write_temp_file(tmp, "svc/repo.go", + "package svc\n" + "\n" + "type Repo struct {\n" + "\tn int\n" + "}\n" + "\n" + "func (r *Repo) Install() int {\n" + "\treturn r.helper()\n" + "}\n"); + write_temp_file(tmp, "svc/helper.go", + "package svc\n" + "\n" + "func (r *Repo) helper() int {\n" + "\treturn r.n\n" + "}\n"); + write_temp_file(tmp, "other/other.go", + "package other\n" + "\n" + "type Decoy struct{}\n" + "\n" + "func (d *Decoy) helper() int { return 2 }\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_mcall.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_TRUE(cross_file_call_exists(s, project, "Install", "helper")); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + TEST(pipeline_go_receiver_suppresses_weak_method_edge) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_XXXXXX"); @@ -13208,6 +13259,7 @@ SUITE(pipeline) { #endif RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); 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_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); From adff62e7f20dd439129d17c5dc028c7069af92f2 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sat, 29 Aug 2026 18:35:32 +0200 Subject: [PATCH 06/97] fix(extract): give each Go init() its own QN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go allows any number of init() functions per package — even several in one file — and all of them run at start-up. On the flat QN (proj.pkg.init) they all collided and the graph upsert kept ONE node per package, silently dropping the rest: measured on a real Go repo, an event-source package registering each decoder in its own file's init() collapsed 21 init functions into one node (29 in source, 7 in the graph), erasing the whole registration pattern. Disambiguate with the #495 cfg-twin pattern: fold the file basename and line into the QN (proj.pkg.init#a.go:L5). Calling init explicitly is illegal in Go, so nothing ever joins on the plain QN; the call-scope side (compute_func_qn) mirrors the exact formula so init-body calls keep their source attribution instead of degrading to the calls_find_source File fallback. Reproduce-first: pipeline_go_multi_init_nodes_survive is RED without the def-side change (node count 1, expected 2) and GREEN with it; extract_go_multiple_init_disambiguated pins two same-file inits to distinct suffixed QNs. Fixes #1910 Signed-off-by: Ilya Brykau --- internal/cbm/extract_defs.c | 19 ++++++++++ internal/cbm/extract_unified.c | 16 +++++++++ internal/cbm/helpers.c | 36 +++++++++++++++++++ internal/cbm/helpers.h | 4 +++ tests/test_extraction.c | 63 ++++++++++++++++++++++++++++++++++ tests/test_pipeline.c | 50 +++++++++++++++++++++++++++ 6 files changed, 188 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b298ec50ca..af49ef4858 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3739,6 +3739,25 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } } + /* Go allows any number of init() functions per package — even several in + * one file — and they all run at start-up. On the flat QN they all + * collided and the graph upsert kept ONE node per package, silently + * dropping the rest (#1910). Disambiguate with the #495 cfg-twin pattern: + * fold the file basename and the init's per-file ordinal into the QN. + * The ordinal (not the line) keeps the QN stable when code is edited + * above the function — the QN is node identity, and a line suffix would + * churn nodes on every unrelated insertion. Nothing ever joins on the + * plain QN — calling init explicitly is illegal in Go — and the + * call-scope side (compute_func_qn in extract_unified.c) computes the + * same cbm_go_init_ordinal so init-body calls keep their attribution. */ + if (ctx->language == CBM_LANG_GO && strcmp(def.label, "Function") == 0 && + strcmp(name, "init") == 0) { + const char *base = strrchr(ctx->rel_path, '/'); + base = base ? base + 1 : ctx->rel_path; + def.qualified_name = cbm_arena_sprintf(a, "%s#%s:%d", def.qualified_name, base, + cbm_go_init_ordinal(node, ctx->source)); + } + // C++/CUDA: out-of-line method definition (`Foo::bar` in a .cc/.cpp). The // class body in the header is declaration-only, so without this the // definition is recorded as a free Function. Promote it to a Method whose QN diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 8502c37b81..1bcbd5753d 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -918,6 +918,22 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan } } + /* Go init(): the def extractor folds the file basename and the init's + * per-file ordinal into the QN so every init in a package survives the + * upsert (#1910, the #495 cfg-twin pattern; ordinal, not line, so the QN + * is stable under edits above the function). Mirror the exact formula + * here, or init-body calls fall back to File-node attribution + * (calls_find_source). */ + if (ctx->language == CBM_LANG_GO && strcmp(name, "init") == 0 && + strcmp(ts_node_type(node), "function_declaration") == 0) { + const char *base_qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, + name, ctx->language); + const char *base = strrchr(ctx->rel_path, '/'); + base = base ? base + 1 : ctx->rel_path; + return cbm_arena_sprintf(ctx->arena, "%s#%s:%d", base_qn, base, + cbm_go_init_ordinal(node, ctx->source)); + } + /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def * extractor bakes it into the def QN. Compose it identically here — otherwise * an in-body call sources to a QN one or more segments short of the def, and diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index e8a6eb8ecc..5ac97f6100 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1734,6 +1734,42 @@ int cbm_classify_string(const char *str, int len) { * canonical parameter shape of server-side route paths * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields * no text or exceeds the route-sized buffer. */ +/* 1-based source-order ordinal of a top-level Go `init` function_declaration + * among the file's `init`s. Both the definition extractor and compute_func_qn + * fold it into the QN (`pkg.init#file.go:N`, #1910), so it must be computed + * from the tree on both sides with this one formula. Unlike a line number, + * the ordinal is stable under edits above the function — QNs are node + * identity, and a line-based suffix would churn nodes on every unrelated + * insertion (the #495 cfg suffix and the GoogleTest derivation are both + * edit-stable for the same reason). */ +int cbm_go_init_ordinal(TSNode fn_node, const char *source) { + TSNode root = fn_node; + for (TSNode p = ts_node_parent(root); !ts_node_is_null(p); p = ts_node_parent(p)) { + root = p; + } + int ordinal = 0; + uint32_t n = ts_node_named_child_count(root); + for (uint32_t i = 0; i < n; i++) { + TSNode child = ts_node_named_child(root, i); + if (strcmp(ts_node_type(child), "function_declaration") != 0) { + continue; + } + TSNode name = ts_node_child_by_field_name(child, TS_FIELD("name")); + if (ts_node_is_null(name)) { + continue; + } + uint32_t start = ts_node_start_byte(name); + if (ts_node_end_byte(name) - start != 4 || memcmp(source + start, "init", 4) != 0) { + continue; + } + ordinal++; + if (ts_node_eq(child, fn_node)) { + return ordinal; + } + } + return ordinal; +} + const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source) { enum { TPL_BUF = 512 }; char buf[TPL_BUF]; diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 29a8583284..e2f148ecfe 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -197,6 +197,10 @@ bool cbm_is_loop_node_type(const char *kind); // Is this a module-level node? (not nested inside function/class body) bool cbm_is_module_level(TSNode node, CBMLanguage lang); +/* 1-based source-order ordinal of a Go `init` among its file's top-level + * `init` function_declarations (QN disambiguation, #1910). */ +int cbm_go_init_ordinal(TSNode fn_node, const char *source); + // Same check, but the node's PARENT is supplied directly — avoids the // O(n) ts_node_parent rescan. Use at call sites iterating a known // parent's children (the common case). `parent` is the parent of the diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 8f5fbf2d2e..edecba47df 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4899,6 +4899,68 @@ TEST(extract_go_method_receiver_qualified_qn) { PASS(); } +TEST(extract_go_multiple_init_disambiguated) { + /* Go allows several init() per package (even per file); each gets a + * file+ordinal-suffixed QN (#495 cfg-twin pattern) so none is lost to the + * same-QN upsert. The ordinal — unlike a line number — must not move when + * code is inserted above the function: the QN is node identity, and a + * line-based suffix would churn nodes on every unrelated edit. */ + CBMFileResult *r = extract("package m\n" + "func init() { a() }\n" + "func init() { b() }\n" + "func a() {}\n" + "func b() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const char *first = NULL; + const char *second = NULL; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (!d->name || strcmp(d->name, "init") != 0) { + continue; + } + if (!first) { + first = d->qualified_name; + } else { + second = d->qualified_name; + } + } + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + ASSERT_STR_EQ(first, "t.init#x.go:1"); + ASSERT_STR_EQ(second, "t.init#x.go:2"); + + /* Stability: an insertion ABOVE both inits must not change either QN. */ + CBMFileResult *r2 = extract("package m\n" + "import \"fmt\"\n" + "var shifted = fmt.Sprint(\"pad\")\n" + "func init() { a() }\n" + "func init() { b() }\n" + "func a() {}\n" + "func b() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r2); + ASSERT_FALSE(r2->has_error); + int seen = 0; + for (int i = 0; i < r2->defs.count; i++) { + const CBMDefinition *d = &r2->defs.items[i]; + if (!d->name || strcmp(d->name, "init") != 0) { + continue; + } + seen++; + if (seen == 1) { + ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:1"); + } else { + ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:2"); + } + } + ASSERT_EQ(seen, 2); + cbm_free_result(r2); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -6580,6 +6642,7 @@ SUITE(extraction) { RUN_TEST(extract_python_member_call_flags_is_method); RUN_TEST(extract_go_selector_call_flags_is_method); RUN_TEST(extract_go_method_receiver_qualified_qn); + RUN_TEST(extract_go_multiple_init_disambiguated); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 96eac87148..a5d5b545f8 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4837,6 +4837,55 @@ TEST(pipeline_go_receiver_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_multi_init_nodes_survive) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* Two files, one package, one init() each — Go runs both at start-up. + * Pre-fix both collapsed onto one QN and the upsert kept one node + * (reproduce-first: RED asserts node count == 2). */ + write_temp_file(tmp, "go.mod", "module example.com/reg\n\ngo 1.22\n"); + write_temp_file(tmp, "reg/a.go", + "package reg\n" + "\n" + "var handlers = map[string]func(){}\n" + "\n" + "func init() { handlers[\"a\"] = handleA }\n" + "\n" + "func handleA() {}\n"); + write_temp_file(tmp, "reg/b.go", + "package reg\n" + "\n" + "func init() { handlers[\"b\"] = handleB }\n" + "\n" + "func handleB() {}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_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); + + /* Both init functions survive as distinct nodes. */ + cbm_node_t *inits = NULL; + int ic = 0; + cbm_store_find_nodes_by_name(s, project, "init", &inits, &ic); + ASSERT_EQ(ic, 2); + cbm_store_free_nodes(inits, ic); + + 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 @@ -13261,6 +13310,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_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); RUN_TEST(pipeline_go_bare_ref_never_binds_field); From ac46c17fc37f78ae518145e73d51b46ca3013fac Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sun, 30 Aug 2026 13:43:40 +0200 Subject: [PATCH 07/97] 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 0a43a3f5e5..6fffffea08 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 a5d5b545f8..666b4674da 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 1891d448054ae8ff111d2797d91ec93bf3820907 Mon Sep 17 00:00:00 2001 From: Vladislav Rahmanov Date: Thu, 10 Sep 2026 17:50:01 +0300 Subject: [PATCH 08/97] feat(cli): grade index_repository outcomes with distinct exit codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshness gate cannot read a tool's account of itself — it reads the process status. index_repository returned 0 for a partial index and the same 1 for both a missing repository and a pipeline that fell over inside an existing one, so no caller could tell those apart. The CLI now grades its own outcome: 0 indexed, quality at or above the thresholds 1 hard failure inside a reachable repository (unchanged meaning) 2 indexed but below a quality threshold 3 repo_path absent, unreadable, or not a directory The contract is opt-in: it applies only with CBM_GATE set to a non-empty value other than "0". Without it the CLI exits exactly as before, so a caller that never asked for grading never sees a new code. Grading happens in the CLI only. MCP responses are untouched, so agents and the soak test, which judge by JSON-RPC and tool-level isError, see exactly what they saw before. Thresholds are separate on purpose and overridable: CBM_GATE_MAX_UNUSABLE default 0 a file that did not parse at all CBM_GATE_MAX_PARTIAL_PCT default 10 share of partially parsed files Partial parsing marks constructs a grammar does not cover — a property of language support, not of index quality; this repository itself sits at 5.8%. A gate its own project cannot pass gets switched off. Two additive fields carry what the grading needs: files_indexed, parse_partial_pct the denominator the ratio needs and the share stated outright, so a gate can quote it in a refusal reason on failures: target_unavailable vs pipeline_failed Signed-off-by: Vladislav Rahmanov --- src/cli/cli.c | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/cli/cli.h | 42 +++++++++++++++++++++++++++++ src/main.c | 5 ++++ src/mcp/mcp.c | 32 ++++++++++++++++++++++ 4 files changed, 154 insertions(+) diff --git a/src/cli/cli.c b/src/cli/cli.c index f47c3031ca..4ce60e9c47 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -157,6 +157,81 @@ int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_canc return maintenance_cancelled && exit_status == EXIT_SUCCESS ? EXIT_FAILURE : exit_status; } +/* One override, parsed strictly: a typo must not silently disable a gate, so + * anything that is not a whole number falls back to the documented default. */ +static long cli_gate_threshold(const char *name, long fallback) { + const char *raw = getenv(name); + if (!raw || !raw[0]) { + return fallback; + } + char *end = NULL; + errno = 0; + long value = strtol(raw, &end, 10); + /* Overflow clamps to LONG_MAX with the whole string consumed, which would + * read as a ceiling no run can exceed: the one typo that disables a gate. */ + if (errno == ERANGE || end == raw || *end != '\0') { + return fallback; + } + return value; +} + +/* The contract is opt-in. Unset, empty, or "0" leaves the historical 0/1 + * status untouched, so no existing caller sees a new code until it asks. */ +static bool cli_gate_enabled(void) { + const char *raw = getenv("CBM_GATE"); + return raw && raw[0] && strcmp(raw, "0") != 0; +} + +int cbm_cli_index_exit_status(const char *result, int base_status) { + if (!result || !cli_gate_enabled()) { + return base_status; + } + yyjson_doc *envelope = yyjson_read(result, strlen(result), 0); + if (!envelope) { + return base_status; + } + yyjson_val *root = yyjson_doc_get_root(envelope); + yyjson_val *content = yyjson_is_obj(root) ? yyjson_obj_get(root, "content") : NULL; + yyjson_val *first = yyjson_is_arr(content) ? yyjson_arr_get_first(content) : NULL; + const char *text = first ? yyjson_get_str(yyjson_obj_get(first, "text")) : NULL; + + int status = base_status; + yyjson_doc *payload = text ? yyjson_read(text, strlen(text), 0) : NULL; + yyjson_val *proot = payload ? yyjson_doc_get_root(payload) : NULL; + if (yyjson_is_obj(proot)) { + const char *state = yyjson_get_str(yyjson_obj_get(proot, "status")); + const char *reason = yyjson_get_str(yyjson_obj_get(proot, "reason")); + if (state && strcmp(state, "error") == 0) { + status = (reason && strcmp(reason, "target_unavailable") == 0) + ? CBM_CLI_EXIT_TARGET + : (base_status != CBM_CLI_EXIT_OK ? base_status : CBM_CLI_EXIT_FAILURE); + } else if (base_status == CBM_CLI_EXIT_OK) { + long unusable = (long)yyjson_get_int(yyjson_obj_get(proot, "parse_unusable_count")); + long partial = (long)yyjson_get_int(yyjson_obj_get(proot, "parse_partial_count")); + long files = (long)yyjson_get_int(yyjson_obj_get(proot, "files_indexed")); + long max_unusable = cli_gate_threshold("CBM_GATE_MAX_UNUSABLE", 0); + long max_partial_pct = cli_gate_threshold("CBM_GATE_MAX_PARTIAL_PCT", 10); + /* "degraded" is the pipeline's own verdict that the graph came out + * far smaller than the run expected — a quality failure by any + * reading, so it joins the two parse thresholds. */ + bool degraded = strcmp(state ? state : "", "degraded") == 0; + bool too_many_unusable = max_unusable >= 0 && unusable > max_unusable; + /* Integer arithmetic on purpose: a percentage compared through a + * double would make the threshold depend on rounding. */ + bool too_many_partial = + max_partial_pct >= 0 && files > 0 && partial * 100 > max_partial_pct * files; + if (degraded || too_many_unusable || too_many_partial) { + status = CBM_CLI_EXIT_QUALITY; + } + } + } + if (payload) { + yyjson_doc_free(payload); + } + yyjson_doc_free(envelope); + return status; +} + /* #1537. Two very different failures reached this one message: the cohort was * BUSY (real sessions are running — the reader can close them), or the * reservation failed outright (lock I/O, stale coordination state, permissions diff --git a/src/cli/cli.h b/src/cli/cli.h index 139221f0a3..702dbc95b7 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -57,6 +57,48 @@ bool cbm_cli_mcp_result_is_error(const char *result); * accepted cancellation into EXIT_FAILURE. */ int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_cancelled); +/* ── Index exit contract ────────────────────────────────────────── + * + * A freshness gate must not have to read anyone's account of itself: it reads + * the process status. `index_repository` therefore grades its own outcome. + * + * 0 indexed, quality at or above the thresholds + * 1 hard failure (pipeline fell over inside a reachable repository) + * 2 indexed, but below a quality threshold — a graph that exists and lies + * about how much of the tree it covers is worse than no graph + * 3 target unavailable: repo_path is absent, unreadable, or not a directory + * + * 1 keeps its historical meaning so existing `|| fail` scripts are unaffected; + * 2 and 3 are new and carve out cases that used to return 0 and 1. + * + * The contract is opt-in: it applies only when CBM_GATE is set to a non-empty + * value other than "0". Without it the CLI exits exactly as before, so a + * caller that never asked for grading never sees a new code. + * + * Thresholds, both overridable by environment: + * CBM_GATE_MAX_UNUSABLE (default 0) absolute count of unparsable files + * CBM_GATE_MAX_PARTIAL_PCT (default 10) parse_partial_count / files_indexed + * A threshold set to a negative value disables that check. + * + * The two numbers differ on purpose and must not be collapsed into one: a file + * that did not parse at all is a defect and gets no tolerance, while partial + * parsing marks constructs this grammar does not cover — a property of language + * support, not of index quality, and normal at a few percent in a large tree. + * A gate its own repository cannot pass gets switched off, which protects + * nothing. */ +enum { + CBM_CLI_EXIT_OK = 0, + CBM_CLI_EXIT_FAILURE = 1, + CBM_CLI_EXIT_QUALITY = 2, + CBM_CLI_EXIT_TARGET = 3, +}; + +/* Grade an index_repository result envelope. `base_status` is what the + * ordinary isError mapping already produced; it is preserved unless the + * payload justifies a more specific code. Unparsable payloads change + * nothing — silence is never upgraded into a verdict. */ +int cbm_cli_index_exit_status(const char *result, int base_status); + /* ── Self-update: version comparison ──────────────────────────── */ /* Compare two semver strings (e.g. "0.2.1" vs "0.2.0"). diff --git a/src/main.c b/src/main.c index 53d259b1cf..de7adbc5c5 100644 --- a/src/main.c +++ b/src/main.c @@ -1000,6 +1000,11 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo } else { exit_code = cli_print_mcp_result(result); } + /* One place for both presentations: a gate reading the status must get + * the same verdict whether or not the caller asked for --json. */ + if (tool_name && strcmp(tool_name, "index_repository") == 0) { + exit_code = cbm_cli_index_exit_status(result, exit_code); + } exit_code = cbm_cli_exit_status_after_maintenance(exit_code, maintenance_cancelled); if (cbm_index_worker_active()) { /* The supervisor protocol classifies the PROCESS, not the tool diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index c8978a79d0..888c6c9d98 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -10316,6 +10316,28 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc * add_parse_partial_summary(doc, root, file_errors, file_error_count); add_parse_unusable_summary(doc, root, file_errors, file_error_count); } + /* Denominator for quality gates. parse_partial_count on its own cannot + * say whether sixty flagged files are a rounding error or half the + * repository; file_hashes holds exactly one row per indexed file. */ + if (store) { + cbm_file_hash_t *hashes = NULL; + int hash_count = 0; + if (cbm_store_get_file_hashes(store, project_name, &hashes, &hash_count) == CBM_STORE_OK) { + yyjson_mut_obj_add_int(doc, root, "files_indexed", hash_count); + /* The share, stated outright. A gate that only passes or fails + * cannot tell anyone HOW partial the parse was, and "the graph is + * fresh, but 6.2% of files parsed partially" is a usable hint + * where a bare verdict is not. Tenths of a percent are derived by + * integer division so the number never depends on rounding. */ + yyjson_mut_val *partial_val = yyjson_mut_obj_get(root, "parse_partial_count"); + if (partial_val && hash_count > 0) { + int partial = yyjson_mut_get_int(partial_val); + long tenths = (long)partial * 1000 / hash_count; + yyjson_mut_obj_add_real(doc, root, "parse_partial_pct", (double)tenths / 10.0); + } + cbm_store_free_file_hashes(hashes, hash_count); + } + } int nodes = 0; int edges = 0; bool degraded = false; @@ -11254,6 +11276,16 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { "previous index may have been rolled back."); } else { yyjson_mut_obj_add_str(doc, root, "status", "error"); + /* A repository that is not there at all is a different failure from a + * pipeline that fell over inside one that is — the hint below reads + * identically for both, so callers could not tell them apart. The + * split is deliberately about the ROOT: an unreadable subtree is a + * pipeline failure, not a missing target. */ + cbm_path_info_t target = {0}; + bool target_reachable = + cbm_path_info_utf8(repo_path, &target) == CBM_PATH_INFO_OK && target.is_directory; + yyjson_mut_obj_add_str(doc, root, "reason", + target_reachable ? "pipeline_failed" : "target_unavailable"); yyjson_mut_obj_add_str(doc, root, "hint", "Pipeline failed. Check repo_path exists and contains source files. " "Try mode='fast' for a quicker diagnostic run."); From b037b488be4620a5e92424e7e390ee625cd7e5af Mon Sep 17 00:00:00 2001 From: Vladislav Rahmanov Date: Mon, 7 Sep 2026 19:56:21 +0300 Subject: [PATCH 09/97] test(cli): pin the index_repository exit contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract landed measured but unpinned: six outcomes were checked by hand against the built binary and written into the pull request, and nothing in the suite would notice if a code moved. A verification that does not run again is a claim, not a test. Twelve cases over cbm_cli_index_exit_status, which grades a result envelope into a process exit code: clean run stays 0; an unusable file and a partial share above the ceiling both grade 2; "degraded" grades 2 on the status alone; an absent target grades 3 while a pipeline failure keeps 1 — the pair that shared code 1 before and gave the whole contract its reason. Three of them exist to fail if the grader stops working rather than to pass when it does: - the ceiling itself is exercised from both sides, 10 of 100 passing and 11 failing, because a test that only checks obvious excess stays green with the check switched off entirely; - the threshold overrides carry a positive control at the defaults, so "passed after raising the ceiling" cannot be confused with "no check ran"; - eight unreadable threshold values are pinned to fall back on the documented default, since a typo that silently disables a gate is the failure the strict parse was written to prevent. Silence is pinned too: a null, empty, unreadable, or content-less envelope preserves the base status in both directions. Inventing a quality verdict out of an unparsable payload would fail runs for the crime of an unexpected response shape. Envelopes are built with yyjson rather than hand-escaped, so the payload's quoting is the transport's and not the test's own. Signed-off-by: Vladislav Rahmanov --- tests/test_cli.c | 328 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) diff --git a/tests/test_cli.c b/tests/test_cli.c index 44259a63c2..7d5a8f4454 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -14886,6 +14886,319 @@ TEST(cli_update_only_names_an_installer_that_exists_issue1632) { PASS(); } +/* ── index_repository exit contract ────────────────────────────── + * + * cbm_cli_index_exit_status grades a result envelope into a process exit + * code, because a freshness gate reads process status rather than a tool's + * account of itself. The six outcomes were measured by hand against the + * built binary when the contract landed; measured once is not pinned, and + * a code that moves silently is exactly what the contract exists to stop. */ + +/* Build the envelope the CLI actually receives: the payload travels as a + * JSON string inside content[0].text. Escaping it by hand in every test + * would put the test's own escaping on trial instead of the grader. */ +static char *cli_index_envelope(const char *payload) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + if (!doc) { + return NULL; + } + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_val *content = yyjson_mut_arr(doc); + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_str(doc, item, "text", payload); + yyjson_mut_arr_append(content, item); + yyjson_mut_obj_add_val(doc, root, "content", content); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} + +/* Grade one payload. Returns -1 only if the envelope could not be built, a + * value no exit code uses, so a setup failure cannot pass for a verdict. */ +static int cli_index_grade(const char *payload, int base_status) { + char *envelope = cli_index_envelope(payload); + if (!envelope) { + return -1; + } + int status = cbm_cli_index_exit_status(envelope, base_status); + free(envelope); + return status; +} + +typedef struct { + char *gate; + char *unusable; + char *partial_pct; +} cli_gate_env_t; + +/* A test that means to exercise the documented defaults must not inherit + * whatever the developer happens to have exported. The contract is opt-in, + * so the gate is switched on here; the one test about it being off unsets + * CBM_GATE itself. */ +static cli_gate_env_t cli_gate_env_clear(void) { + cli_gate_env_t saved = {save_test_env("CBM_GATE"), save_test_env("CBM_GATE_MAX_UNUSABLE"), + save_test_env("CBM_GATE_MAX_PARTIAL_PCT")}; + cbm_setenv("CBM_GATE", "1", 1); + cbm_unsetenv("CBM_GATE_MAX_UNUSABLE"); + cbm_unsetenv("CBM_GATE_MAX_PARTIAL_PCT"); + return saved; +} + +static void cli_gate_env_restore(cli_gate_env_t saved) { + restore_test_env("CBM_GATE", saved.gate); + restore_test_env("CBM_GATE_MAX_UNUSABLE", saved.unusable); + restore_test_env("CBM_GATE_MAX_PARTIAL_PCT", saved.partial_pct); +} + +/* Without CBM_GATE the CLI exits exactly as it always did: a payload that + * would grade 2 or 3 with the gate on keeps the base status. "0" and the + * empty string count as off, so an exported CBM_GATE=0 is not a surprise. */ +TEST(cli_index_exit_gate_off_keeps_base_status) { + cli_gate_env_t saved = cli_gate_env_clear(); + const char *below_quality = "{\"status\":\"ok\",\"files_indexed\":10," + "\"parse_partial_count\":5,\"parse_unusable_count\":1}"; + const char *missing_target = "{\"status\":\"error\",\"reason\":\"target_unavailable\"}"; + + /* Positive control: with the gate on both payloads DO change the status, + * so the unchanged codes below are the switch working, not a dead check. */ + int quality_on = cli_index_grade(below_quality, CBM_CLI_EXIT_OK); + int target_on = cli_index_grade(missing_target, CBM_CLI_EXIT_FAILURE); + + cbm_unsetenv("CBM_GATE"); + int quality_unset = cli_index_grade(below_quality, CBM_CLI_EXIT_OK); + int target_unset = cli_index_grade(missing_target, CBM_CLI_EXIT_FAILURE); + + cbm_setenv("CBM_GATE", "0", 1); + int quality_zero = cli_index_grade(below_quality, CBM_CLI_EXIT_OK); + + cbm_setenv("CBM_GATE", "", 1); + int quality_empty = cli_index_grade(below_quality, CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + + ASSERT_EQ(quality_on, CBM_CLI_EXIT_QUALITY); + ASSERT_EQ(target_on, CBM_CLI_EXIT_TARGET); + ASSERT_EQ(quality_unset, CBM_CLI_EXIT_OK); + ASSERT_EQ(target_unset, CBM_CLI_EXIT_FAILURE); + ASSERT_EQ(quality_zero, CBM_CLI_EXIT_OK); + ASSERT_EQ(quality_empty, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A clean index keeps the code it always had: the contract adds verdicts, + * it does not make previously good runs start failing. */ +TEST(cli_index_exit_clean_run_stays_zero) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":4," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A file that did not parse at all gets no tolerance — the default is zero, + * so one such file is already a quality failure. */ +TEST(cli_index_exit_unusable_file_is_a_quality_failure) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":5," + "\"parse_partial_count\":0,\"parse_unusable_count\":1}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* Partial parsing is graded as a share, not a count: 20 of 100 files is + * twice the default ceiling. */ +TEST(cli_index_exit_partial_above_threshold_is_a_quality_failure) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":20,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* The boundary itself passes. The comparison is strict and integral on + * purpose: at exactly the documented ceiling a double could round either + * way, and a threshold whose verdict depends on rounding is not a + * threshold. 10 of 100 is the ceiling, and the ceiling is allowed. */ +TEST(cli_index_exit_partial_exactly_at_threshold_passes) { + cli_gate_env_t saved = cli_gate_env_clear(); + int at_ceiling = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":10,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + /* One file more is over it, which proves the case above is the boundary + * and not simply a check that never fires. */ + int over_ceiling = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":11,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(at_ceiling, CBM_CLI_EXIT_OK); + ASSERT_EQ(over_ceiling, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* "degraded" is the pipeline's own verdict that the graph came out far + * smaller than the run expected. It carries no parse counts, so it has to + * be graded on the status alone. */ +TEST(cli_index_exit_degraded_status_is_a_quality_failure) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"degraded\",\"files_indexed\":40," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* An absent target and a pipeline that fell over inside a reachable + * repository used to share code 1, which is the whole reason the contract + * was written: the caller could not tell "wrong path" from "broken run". */ +TEST(cli_index_exit_separates_missing_target_from_broken_run) { + cli_gate_env_t saved = cli_gate_env_clear(); + int target = cli_index_grade("{\"status\":\"error\",\"reason\":\"target_unavailable\"}", + CBM_CLI_EXIT_FAILURE); + int pipeline = cli_index_grade("{\"status\":\"error\",\"reason\":\"pipeline_failed\"}", + CBM_CLI_EXIT_FAILURE); + /* An error the mapping had not already flagged still has to land on a + * failure code rather than fall through as success. */ + int unflagged = cli_index_grade("{\"status\":\"error\",\"reason\":\"pipeline_failed\"}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(target, CBM_CLI_EXIT_TARGET); + ASSERT_EQ(pipeline, CBM_CLI_EXIT_FAILURE); + ASSERT_EQ(unflagged, CBM_CLI_EXIT_FAILURE); + PASS(); +} + +/* Silence is never upgraded into a verdict. A payload the grader cannot + * read says nothing about index quality, and inventing a 2 from it would + * fail runs for the crime of an unexpected response shape. */ +TEST(cli_index_exit_never_upgrades_silence) { + cli_gate_env_t saved = cli_gate_env_clear(); + ASSERT_EQ(cbm_cli_index_exit_status(NULL, CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + ASSERT_EQ(cbm_cli_index_exit_status("", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + ASSERT_EQ(cbm_cli_index_exit_status("not json at all", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + /* A well-formed envelope carrying no content, and one whose text is not + * itself JSON — both are shapes a future response could take. */ + ASSERT_EQ(cbm_cli_index_exit_status("{\"content\":[]}", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + ASSERT_EQ(cli_index_grade("plain text, not a payload", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + /* And the same shapes must not erase a failure already established. */ + ASSERT_EQ(cbm_cli_index_exit_status(NULL, CBM_CLI_EXIT_FAILURE), CBM_CLI_EXIT_FAILURE); + cli_gate_env_restore(saved); + PASS(); +} + +/* Grading only ever makes a code more specific. A base failure survives a + * payload that looks perfectly healthy, because the transport already knew + * something the payload does not say. */ +TEST(cli_index_exit_does_not_downgrade_a_failing_base) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":9," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_FAILURE); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_FAILURE); + PASS(); +} + +/* Both thresholds are overridable, and both are read at grading time. */ +TEST(cli_index_exit_thresholds_read_the_environment) { + cli_gate_env_t saved = cli_gate_env_clear(); + const char *twenty_percent = "{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":20,\"parse_unusable_count\":0}"; + const char *three_unusable = "{\"status\":\"ok\",\"files_indexed\":50," + "\"parse_partial_count\":0,\"parse_unusable_count\":3}"; + + /* Positive control: at the defaults both payloads fail, so a pass below + * is the override working and not the check being absent. */ + int partial_default = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + int unusable_default = cli_index_grade(three_unusable, CBM_CLI_EXIT_OK); + + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", "50", 1); + cbm_setenv("CBM_GATE_MAX_UNUSABLE", "5", 1); + int partial_raised = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + int unusable_raised = cli_index_grade(three_unusable, CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + + ASSERT_EQ(partial_default, CBM_CLI_EXIT_QUALITY); + ASSERT_EQ(unusable_default, CBM_CLI_EXIT_QUALITY); + ASSERT_EQ(partial_raised, CBM_CLI_EXIT_OK); + ASSERT_EQ(unusable_raised, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A negative ceiling switches its own check off. This is the documented + * escape hatch for a repository whose grammars are known to be thin. */ +TEST(cli_index_exit_negative_threshold_disables_the_check) { + cli_gate_env_t saved = cli_gate_env_clear(); + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", "-1", 1); + cbm_setenv("CBM_GATE_MAX_UNUSABLE", "-1", 1); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":99,\"parse_unusable_count\":7}", + CBM_CLI_EXIT_OK); + /* Disabling the parse checks must not disable the pipeline's own + * verdict: "degraded" is not a threshold and has no off switch. */ + int degraded = cli_index_grade("{\"status\":\"degraded\",\"files_indexed\":10," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_OK); + ASSERT_EQ(degraded, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* A typo must not silently disable a gate. Anything that is not a whole + * number falls back to the documented default, so "1O" (letter O) fails the + * run it would have failed anyway instead of quietly waving it through. */ +TEST(cli_index_exit_unreadable_threshold_falls_back_to_default) { + cli_gate_env_t saved = cli_gate_env_clear(); + const char *twenty_percent = "{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":20,\"parse_unusable_count\":0}"; + /* The last one overflows long: strtol clamps it to LONG_MAX with the + * whole string consumed, a ceiling no run could exceed. */ + const char *unreadable[] = { + "abc", "1O", "10pct", " 10", "10 ", "", "1e1", "10.0", "99999999999999999999999"}; + for (size_t i = 0; i < sizeof(unreadable) / sizeof(unreadable[0]); i++) { + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", unreadable[i], 1); + int status = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + if (status != CBM_CLI_EXIT_QUALITY) { + printf(" unreadable threshold \"%s\" gave exit %d\n", unreadable[i], status); + } + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + } + /* A value that DOES read still takes effect, so the loop above is about + * unreadable text and not about the override being ignored outright. */ + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", "50", 1); + int readable = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(readable, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A share needs a denominator. Without files_indexed the percentage cannot + * be computed at all, and a run must not be failed on a number nobody + * could work out. */ +TEST(cli_index_exit_partial_without_denominator_is_not_graded) { + cli_gate_env_t saved = cli_gate_env_clear(); + int no_denominator = cli_index_grade("{\"status\":\"ok\",\"parse_partial_count\":20}", + CBM_CLI_EXIT_OK); + int zero_denominator = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":0," + "\"parse_partial_count\":20}", + CBM_CLI_EXIT_OK); + /* An unusable file is an absolute count and still grades without one. */ + int unusable_still_graded = cli_index_grade("{\"status\":\"ok\",\"parse_unusable_count\":1}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(no_denominator, CBM_CLI_EXIT_OK); + ASSERT_EQ(zero_denominator, CBM_CLI_EXIT_OK); + ASSERT_EQ(unusable_still_graded, CBM_CLI_EXIT_QUALITY); + PASS(); +} + SUITE(cli) { if (!th_secure_runtime_parent_new(g_cli_suite_runtime_parent, sizeof(g_cli_suite_runtime_parent), "cli-suite")) { @@ -15304,6 +15617,21 @@ SUITE(cli) { /* Stdin argument gate (#1359) */ RUN_TEST(cli_zero_argument_tool_never_reads_stdin_issue1359); RUN_TEST(cli_stdin_args_gate_tracks_tool_schema_issue1359); + + /* index_repository exit contract */ + RUN_TEST(cli_index_exit_gate_off_keeps_base_status); + RUN_TEST(cli_index_exit_clean_run_stays_zero); + RUN_TEST(cli_index_exit_unusable_file_is_a_quality_failure); + RUN_TEST(cli_index_exit_partial_above_threshold_is_a_quality_failure); + RUN_TEST(cli_index_exit_partial_exactly_at_threshold_passes); + RUN_TEST(cli_index_exit_degraded_status_is_a_quality_failure); + RUN_TEST(cli_index_exit_separates_missing_target_from_broken_run); + RUN_TEST(cli_index_exit_never_upgrades_silence); + RUN_TEST(cli_index_exit_does_not_downgrade_a_failing_base); + RUN_TEST(cli_index_exit_thresholds_read_the_environment); + RUN_TEST(cli_index_exit_negative_threshold_disables_the_check); + RUN_TEST(cli_index_exit_unreadable_threshold_falls_back_to_default); + RUN_TEST(cli_index_exit_partial_without_denominator_is_not_graded); cbm_cli_set_activation_runtime_parent_for_test(NULL); test_rmdir_r(g_cli_suite_runtime_parent); g_cli_suite_runtime_parent[0] = '\0'; From 53d8bb406b63e8a3354ffa36e89856ec44b7cdf6 Mon Sep 17 00:00:00 2001 From: Benyamin Date: Fri, 21 Aug 2026 14:20:35 +0330 Subject: [PATCH 10/97] fix(codex): migrate skill to shared agents root Signed-off-by: Benyamin --- README.md | 7 +- scripts/smoke-test.sh | 102 +++++++++++++ src/cli/cli.c | 331 ++++++++++++++++++++++++++++++++++++++++- tests/test_cli.c | 337 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 761 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c6ab67ee25..260f7c4d63 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ overwrite user-modified agents. | Agent | Activation | MCP config | Durable context / augmentation | |-------|------------|------------|--------------------------------| | Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, and post-`Read` coverage | -| Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart` | +| Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, `~/.agents/skills/codebase-memory/SKILL.md`, three read-only agents; `SessionStart` + `SubagentStart` | | Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` | | Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill | | OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection | @@ -540,6 +540,11 @@ For Codex, install keeps only a tiny managed activation pointer in global `$CODEX_HOME/AGENTS.md`; all detailed behavior lives in the installed `codebase-memory` skill. Fresh installs create the pointer, upgrades replace the legacy full managed block while preserving all user-owned bytes, and uninstall removes only the managed pointer. +Skill placement follows the cross-client user skill root and does not follow +`CODEX_HOME`. On upgrade, the installer migrates an installer-owned legacy +`$CODEX_HOME/skills/codebase-memory/SKILL.md` only after verifying the canonical +copy. Modified or foreign files are preserved; `--force` explicitly authorizes +replacement before migration. ### Sessions, compaction, and subagents diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index ca27edcc69..620e73c66e 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1313,6 +1313,108 @@ fi echo "" echo "=== Phase 8: agent config install E2E ===" +# Keep Codex skill placement independent from the broad multi-client fixture: +# Warp and OpenHands share the canonical root and could otherwise mask a Codex +# regression by creating the expected file themselves. +CODEX_ONLY_HOME=$(smoke_mktemp_dir) +CODEX_ONLY_CONFIG="$CODEX_ONLY_HOME/vendor-codex" +CODEX_ONLY_INSTALL="$CODEX_ONLY_HOME/bin" +CODEX_ONLY_SKILL="$CODEX_ONLY_HOME/.agents/skills/codebase-memory/SKILL.md" +CODEX_ONLY_LEGACY="$CODEX_ONLY_CONFIG/skills/codebase-memory/SKILL.md" +mkdir -p "$CODEX_ONLY_CONFIG" "$CODEX_ONLY_INSTALL" +if [[ "$BINARY" == *.exe ]]; then + CODEX_ONLY_BIN="$CODEX_ONLY_INSTALL/codebase-memory-mcp.exe" +else + CODEX_ONLY_BIN="$CODEX_ONLY_INSTALL/codebase-memory-mcp" +fi +CODEX_ONLY_LOG=$(smoke_mktemp_file) +run_codex_only() { + local executable="$1" + shift + HOME="$CODEX_ONLY_HOME" \ + XDG_CONFIG_HOME="$CODEX_ONLY_HOME/.config" \ + APPDATA="$CODEX_ONLY_HOME/AppData/Roaming" \ + LOCALAPPDATA="$CODEX_ONLY_HOME/AppData/Local" \ + CODEX_HOME="$CODEX_ONLY_CONFIG" \ + "$executable" "$@" > "$CODEX_ONLY_LOG" 2>&1 +} +if ! run_codex_only "$BINARY" install -y --clients=codex --dir="$CODEX_ONLY_INSTALL"; then + echo "FAIL 8-codex-1: isolated Codex install failed" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +if ! grep -q '^name: codebase-memory$' "$CODEX_ONLY_SKILL" 2>/dev/null || + ! grep -q 'trace_path' "$CODEX_ONLY_SKILL" 2>/dev/null || + [ -e "$CODEX_ONLY_LEGACY" ]; then + echo "FAIL 8-codex-1: Codex skill was not installed exclusively at the canonical path" + cat "$CODEX_ONLY_LOG" + exit 1 +fi + +CODEX_ONLY_HASH=$(smoke_file_sha256 "$CODEX_ONLY_SKILL") +if ! run_codex_only "$CODEX_ONLY_BIN" install --plan --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + [ "$(smoke_file_sha256 "$CODEX_ONLY_SKILL")" != "$CODEX_ONLY_HASH" ]; then + echo "FAIL 8-codex-2: Codex install plan mutated the canonical skill" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +if ! run_codex_only "$CODEX_ONLY_BIN" install --dry-run -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + [ "$(smoke_file_sha256 "$CODEX_ONLY_SKILL")" != "$CODEX_ONLY_HASH" ]; then + echo "FAIL 8-codex-2: Codex dry-run mutated the canonical skill" + cat "$CODEX_ONLY_LOG" + exit 1 +fi + +mkdir -p "$(dirname "$CODEX_ONLY_LEGACY")" +mv "$CODEX_ONLY_SKILL" "$CODEX_ONLY_LEGACY" +if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL"; then + echo "FAIL 8-codex-3: isolated Codex legacy migration failed" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +if ! grep -q '^name: codebase-memory$' "$CODEX_ONLY_SKILL" 2>/dev/null || + [ -e "$CODEX_ONLY_LEGACY" ]; then + echo "FAIL 8-codex-3: Codex legacy skill was not migrated to the canonical path" + cat "$CODEX_ONLY_LOG" + exit 1 +fi + +rm -f "$CODEX_ONLY_SKILL" +mkdir -p "$(dirname "$CODEX_ONLY_LEGACY")" +printf '%s\n' '---' 'name: codebase-memory' '---' 'User-owned legacy skill.' \ + > "$CODEX_ONLY_LEGACY" +CODEX_ONLY_FOREIGN_HASH=$(smoke_file_sha256 "$CODEX_ONLY_LEGACY") +if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + [ -e "$CODEX_ONLY_SKILL" ] || + [ "$(smoke_file_sha256 "$CODEX_ONLY_LEGACY")" != "$CODEX_ONLY_FOREIGN_HASH" ]; then + echo "FAIL 8-codex-4: non-force install did not preserve a foreign legacy skill" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +if ! run_codex_only "$CODEX_ONLY_BIN" install --force -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + ! grep -q '^name: codebase-memory$' "$CODEX_ONLY_SKILL" 2>/dev/null || + [ -e "$CODEX_ONLY_LEGACY" ]; then + echo "FAIL 8-codex-5: force did not converge a foreign legacy skill" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +CODEX_ONLY_HASH=$(smoke_file_sha256 "$CODEX_ONLY_SKILL") +if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + [ "$(smoke_file_sha256 "$CODEX_ONLY_SKILL")" != "$CODEX_ONLY_HASH" ]; then + echo "FAIL 8-codex-6: repeated Codex install was not idempotent" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +echo "OK: Codex skill placement, previews, migration, force, and idempotence" +smoke_rmtree "$CODEX_ONLY_HOME" +rm -f "$CODEX_ONLY_LOG" + # Set up an isolated HOME. Directory-only agents get only the root required for # detection; CLI-detected agents use stubs below so install must create their # config parents from scratch. diff --git a/src/cli/cli.c b/src/cli/cli.c index 51fbafc022..8401179ab5 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -8112,7 +8112,7 @@ static void print_detected_agents(const cbm_detected_agents_t *a, const char *ho * behavior (it is the same code path with mutations disabled). */ typedef struct { char agent[CLI_BUF_32]; - char kind[CLI_BUF_32]; /* mcp_config | instructions | skills | hook */ + char kind[CLI_BUF_32]; /* mcp_config | instructions | skill | cleanup_skill | hook */ char path[CLI_BUF_1K]; } cbm_plan_entry_t; @@ -8125,6 +8125,7 @@ typedef struct { static cbm_install_plan_t *g_install_plan = NULL; static int g_agent_install_errors = 0; static int g_agent_uninstall_errors = 0; +static char g_blocked_shared_skill_dir[CLI_BUF_1K]; /* Every agent configuration uninstall could not clean, kept for the closing * summary. A cleanup failure no longer stops executable and index removal @@ -8610,6 +8611,14 @@ static void install_agent_skill(const char *label, const char *skills_dir, bool if (written < 0 || (size_t)written >= sizeof(skill_path)) { return; } + if (g_blocked_shared_skill_dir[0] && + (cbm_json_mcp_paths_equal(skills_dir, g_blocked_shared_skill_dir) || + cbm_same_file(skills_dir, g_blocked_shared_skill_dir))) { + if (!g_install_plan) { + printf(" skill: %s (skipped to avoid a duplicate Codex skill)\n", skill_path); + } + return; + } if (g_install_plan) { plan_record(label, "skill", skill_path); return; @@ -8618,6 +8627,272 @@ static void install_agent_skill(const char *label, const char *skills_dir, bool printf(" skill: %s (%d installed)\n", skill_path, installed); } +typedef enum { + CBM_CODEX_SKILL_ABSENT, + CBM_CODEX_SKILL_MANAGED, + CBM_CODEX_SKILL_UNOWNED, + CBM_CODEX_SKILL_UNSAFE, +} cbm_codex_skill_state_t; + +typedef struct { + char canonical_dir[CLI_BUF_1K]; + char legacy_dir[CLI_BUF_1K]; + char canonical_file[CLI_BUF_1K]; + char legacy_file[CLI_BUF_1K]; + bool same_dir; +} cbm_codex_skill_paths_t; + +static bool cbm_codex_skill_paths_init(const char *home, const char *config_dir, + cbm_codex_skill_paths_t *paths) { + if (!home || !home[0] || !config_dir || !config_dir[0] || !paths) { + return false; + } + memset(paths, 0, sizeof(*paths)); + int canonical_dir = + snprintf(paths->canonical_dir, sizeof(paths->canonical_dir), "%s/.agents/skills", home); + int legacy_dir = + snprintf(paths->legacy_dir, sizeof(paths->legacy_dir), "%s/skills", config_dir); + int canonical_file = snprintf(paths->canonical_file, sizeof(paths->canonical_file), + "%s/codebase-memory/SKILL.md", paths->canonical_dir); + int legacy_file = snprintf(paths->legacy_file, sizeof(paths->legacy_file), + "%s/codebase-memory/SKILL.md", paths->legacy_dir); + if (canonical_dir < 0 || (size_t)canonical_dir >= sizeof(paths->canonical_dir) || + legacy_dir < 0 || (size_t)legacy_dir >= sizeof(paths->legacy_dir) || canonical_file < 0 || + (size_t)canonical_file >= sizeof(paths->canonical_file) || legacy_file < 0 || + (size_t)legacy_file >= sizeof(paths->legacy_file)) { + return false; + } + paths->same_dir = cbm_json_mcp_paths_equal(paths->canonical_dir, paths->legacy_dir) || + cbm_same_file(paths->canonical_dir, paths->legacy_dir); + return true; +} + +static cbm_codex_skill_state_t cbm_codex_skill_state(const char *skills_dir, + const char *skill_file) { + char skill_dir[CLI_BUF_1K]; + int written = snprintf(skill_dir, sizeof(skill_dir), "%s/codebase-memory", skills_dir); + if (written < 0 || (size_t)written >= sizeof(skill_dir)) { + return CBM_CODEX_SKILL_UNSAFE; + } + + struct stat state; + errno = 0; +#ifndef _WIN32 + int result = lstat(skill_dir, &state); +#else + int result = stat(skill_dir, &state); +#endif + if (result != 0) { + return errno == ENOENT ? CBM_CODEX_SKILL_ABSENT : CBM_CODEX_SKILL_UNSAFE; + } + if (!S_ISDIR(state.st_mode)) { + return CBM_CODEX_SKILL_UNSAFE; + } + + errno = 0; +#ifndef _WIN32 + result = lstat(skill_file, &state); +#else + result = stat(skill_file, &state); +#endif + if (result != 0) { + return errno == ENOENT ? CBM_CODEX_SKILL_ABSENT : CBM_CODEX_SKILL_UNSAFE; + } + if (!S_ISREG(state.st_mode)) { + return CBM_CODEX_SKILL_UNSAFE; + } + + int ownership = cbm_text_owned_document_status(skill_file, skill_content, NULL, 0); + if (ownership == CLI_OK) { + return CBM_CODEX_SKILL_MANAGED; + } + return ownership == CLI_TRUE ? CBM_CODEX_SKILL_UNOWNED : CBM_CODEX_SKILL_UNSAFE; +} + +static void cbm_warn_codex_skill_conflict(const cbm_codex_skill_paths_t *paths, + const char *reason) { + (void)fprintf(stderr, + "warning: Codex skill migration skipped: %s; documented_location=%s legacy=%s\n", + reason, paths->canonical_file, paths->legacy_file); +} + +static void cbm_block_shared_skill_install(const cbm_codex_skill_paths_t *paths) { + (void)snprintf(g_blocked_shared_skill_dir, sizeof(g_blocked_shared_skill_dir), "%s", + paths->canonical_dir); +} + +static void cbm_block_shared_skill_if_codex_conflicts(const char *home, const char *config_dir) { + cbm_codex_skill_paths_t paths; + if (!cbm_codex_skill_paths_init(home, config_dir, &paths) || paths.same_dir) { + return; + } + + cbm_codex_skill_state_t canonical = + cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file); + cbm_codex_skill_state_t legacy = cbm_codex_skill_state(paths.legacy_dir, paths.legacy_file); + if (canonical == CBM_CODEX_SKILL_ABSENT && legacy != CBM_CODEX_SKILL_ABSENT) { + cbm_block_shared_skill_install(&paths); + } +} + +static bool cbm_remove_codex_managed_skill(const char *skills_dir, const char *skill_file, + bool dry_run, bool uninstalling, const char *operation) { + cbm_codex_skill_state_t state = cbm_codex_skill_state(skills_dir, skill_file); + if (state == CBM_CODEX_SKILL_ABSENT) { + return true; + } + if (state != CBM_CODEX_SKILL_MANAGED) { + if (state == CBM_CODEX_SKILL_UNOWNED) { + printf(" preserved non-owned Codex skill: %s\n", skill_file); + } else if (dry_run) { + printf(" preserved unsafe Codex skill path: %s\n", skill_file); + } else if (!dry_run) { + record_agent_config_error(uninstalling, "Codex CLI", operation, skill_file); + } + return state == CBM_CODEX_SKILL_UNOWNED; + } + if (dry_run) { + printf(" Codex skill: %s (would remove)\n", skill_file); + return true; + } + int removed = cbm_remove_skills(skills_dir, false); + if (removed == CBM_SKILL_COUNT && + cbm_codex_skill_state(skills_dir, skill_file) == CBM_CODEX_SKILL_ABSENT) { + return true; + } + record_agent_config_error(uninstalling, "Codex CLI", operation, skill_file); + return false; +} + +static void install_codex_skill(const char *home, const char *config_dir, bool force, + bool dry_run) { + cbm_codex_skill_paths_t paths; + if (!cbm_codex_skill_paths_init(home, config_dir, &paths)) { + if (!g_install_plan) { + record_agent_config_error(false, "Codex CLI", "skill_path", config_dir); + } + return; + } + + cbm_codex_skill_state_t canonical = + cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file); + cbm_codex_skill_state_t legacy = + paths.same_dir ? canonical : cbm_codex_skill_state(paths.legacy_dir, paths.legacy_file); + + if (g_install_plan) { + if (canonical == CBM_CODEX_SKILL_ABSENT && + (paths.same_dir || legacy == CBM_CODEX_SKILL_ABSENT || + legacy == CBM_CODEX_SKILL_MANAGED)) { + plan_record("Codex CLI", "skill", paths.canonical_file); + } + if (!paths.same_dir && legacy == CBM_CODEX_SKILL_MANAGED && + (canonical == CBM_CODEX_SKILL_ABSENT || canonical == CBM_CODEX_SKILL_MANAGED)) { + plan_record("Codex CLI", "cleanup_skill", paths.legacy_file); + } + if (canonical == CBM_CODEX_SKILL_ABSENT && + (legacy == CBM_CODEX_SKILL_UNOWNED || legacy == CBM_CODEX_SKILL_UNSAFE)) { + cbm_block_shared_skill_install(&paths); + } + return; + } + + if (paths.same_dir) { + if (canonical == CBM_CODEX_SKILL_UNSAFE) { + record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); + return; + } + if (!force && canonical == CBM_CODEX_SKILL_UNOWNED) { + printf(" preserved non-owned Codex skill: %s\n", paths.canonical_file); + return; + } + install_agent_skill("Codex CLI", paths.canonical_dir, force, dry_run); + if (!dry_run && cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file) != + CBM_CODEX_SKILL_MANAGED) { + record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); + } + return; + } + + if (canonical == CBM_CODEX_SKILL_UNSAFE) { + if (dry_run) { + cbm_warn_codex_skill_conflict(&paths, "the canonical skill path is unsafe"); + } else { + record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); + } + return; + } + if (legacy == CBM_CODEX_SKILL_UNSAFE) { + if (canonical == CBM_CODEX_SKILL_ABSENT) { + cbm_block_shared_skill_install(&paths); + } + if (force && !dry_run) { + record_agent_config_error(false, "Codex CLI", "legacy_skill_migration", + paths.legacy_file); + } else { + cbm_warn_codex_skill_conflict(&paths, "the legacy skill path is unsafe"); + } + return; + } + + if (!force && legacy == CBM_CODEX_SKILL_UNOWNED) { + if (canonical == CBM_CODEX_SKILL_ABSENT) { + cbm_block_shared_skill_install(&paths); + } + cbm_warn_codex_skill_conflict(&paths, "the legacy skill is not installer-owned"); + return; + } + if (!force && canonical == CBM_CODEX_SKILL_UNOWNED) { + if (legacy == CBM_CODEX_SKILL_MANAGED) { + cbm_warn_codex_skill_conflict(&paths, "the canonical skill is not installer-owned"); + } else { + printf(" preserved non-owned Codex skill: %s\n", paths.canonical_file); + } + return; + } + + bool remove_legacy = legacy == CBM_CODEX_SKILL_MANAGED; + if (force && legacy == CBM_CODEX_SKILL_UNOWNED) { + remove_legacy = true; + if (dry_run) { + printf(" legacy Codex skill: %s (would replace for migration)\n", paths.legacy_file); + } else { + int replaced = cbm_install_skills(paths.legacy_dir, true, false); + printf(" legacy Codex skill: %s (%d updated for migration)\n", paths.legacy_file, + replaced); + if (cbm_codex_skill_state(paths.legacy_dir, paths.legacy_file) != + CBM_CODEX_SKILL_MANAGED) { + record_agent_config_error(false, "Codex CLI", "legacy_skill_migration", + paths.legacy_file); + return; + } + } + } + + bool install_canonical = force || canonical == CBM_CODEX_SKILL_ABSENT; + if (install_canonical) { + install_agent_skill("Codex CLI", paths.canonical_dir, force, dry_run); + } else { + printf(" skill: %s (0 installed)\n", paths.canonical_file); + } + + if (dry_run) { + if (remove_legacy) { + printf(" legacy Codex skill: %s (would remove after canonical install)\n", + paths.legacy_file); + } + return; + } + if (cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file) != + CBM_CODEX_SKILL_MANAGED) { + record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); + return; + } + if (remove_legacy && cbm_remove_codex_managed_skill(paths.legacy_dir, paths.legacy_file, false, + false, "legacy_skill_cleanup")) { + printf(" legacy Codex skill: %s (removed)\n", paths.legacy_file); + } +} + /* Derive tier siblings only from the exact shipped Verify basename. This keeps * vendor-specific suffixes such as .agent.md, .toml, and .json intact. */ static int cbm_tiered_profile_path(const char *verify_path, cbm_graph_tier_t tier, char *output, @@ -9502,12 +9777,10 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const char config_dir[CLI_BUF_1K]; char cp[CLI_BUF_1K]; char ip[CLI_BUF_1K]; - char skills_dir[CLI_BUF_1K]; char ap[CLI_BUF_1K]; cbm_codex_config_dir(home, config_dir, sizeof(config_dir)); snprintf(cp, sizeof(cp), "%s/config.toml", config_dir); snprintf(ip, sizeof(ip), "%s/AGENTS.md", config_dir); - snprintf(skills_dir, sizeof(skills_dir), "%s/skills", config_dir); snprintf(ap, sizeof(ap), "%s/agents/codebase-memory.toml", config_dir); /* A broken hook config must not keep legacy full guidance active. */ bool pointer_installed = install_codex_activation_pointer(ip, dry_run); @@ -9528,6 +9801,7 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const true, &preflight_failure) : CLI_ERR; if (preflight_result != CLI_OK) { + cbm_block_shared_skill_if_codex_conflicts(home, config_dir); if (!g_install_plan) { printf("Codex CLI:\n"); fflush(stdout); @@ -9548,7 +9822,7 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const if (!g_install_plan) { report_codex_activation_pointer_install(ip, pointer_installed); } - install_agent_skill("Codex CLI", skills_dir, force, dry_run); + install_codex_skill(home, config_dir, force, dry_run); install_tiered_agent_profiles( (cbm_tiered_profile_set_t){ .label = "Codex CLI", @@ -10332,6 +10606,7 @@ static int cbm_install_agent_configs_in_scope(const char *home, const char *bina int cbm_install_agent_configs(const char *home, const char *binary_path, bool force, bool dry_run) { g_agent_install_errors = 0; + g_blocked_shared_skill_dir[0] = '\0'; cbm_detected_agents_t agents = cbm_detect_agents(home); if (g_client_selection && !cli_clients_apply_selection(g_client_selection, &agents)) { return CLI_ERR; @@ -10837,6 +11112,7 @@ static char *cbm_build_install_plan_json_options(const char *home, const char *b yyjson_mut_val *agent_files = yyjson_mut_arr(doc); yyjson_mut_val *prompt_files = yyjson_mut_arr(doc); yyjson_mut_val *hooks = yyjson_mut_arr(doc); + yyjson_mut_val *cleanup_actions = yyjson_mut_arr(doc); for (int i = 0; i < plan.count; i++) { cbm_plan_entry_t *e = &plan.items[i]; if (strcmp(e->kind, "mcp_config") == 0) { @@ -10855,6 +11131,14 @@ static char *cbm_build_install_plan_json_options(const char *home, const char *b } else if (strcmp(e->kind, "prompt") == 0) { yyjson_mut_arr_add_strcpy(doc, prompt_files, e->path); yyjson_mut_arr_add_strcpy(doc, instrs, e->path); + } else if (strcmp(e->kind, "cleanup_skill") == 0) { + yyjson_mut_val *cleanup = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, cleanup, "agent", e->agent); + yyjson_mut_obj_add_str(doc, cleanup, "kind", "skill"); + yyjson_mut_obj_add_str(doc, cleanup, "operation", + "remove_owned_legacy_copy_if_migrated"); + yyjson_mut_obj_add_strcpy(doc, cleanup, "path", e->path); + yyjson_mut_arr_add_val(cleanup_actions, cleanup); } else { yyjson_mut_arr_add_strcpy(doc, instrs, e->path); } @@ -10865,6 +11149,7 @@ static char *cbm_build_install_plan_json_options(const char *home, const char *b yyjson_mut_obj_add_val(doc, root, "agent_files_planned", agent_files); yyjson_mut_obj_add_val(doc, root, "prompt_files_planned", prompt_files); yyjson_mut_obj_add_val(doc, root, "hooks_planned", hooks); + yyjson_mut_obj_add_val(doc, root, "cleanup_actions_planned", cleanup_actions); yyjson_mut_obj_add_bool(doc, root, "writes_started", false); yyjson_mut_obj_add_bool(doc, root, "network_after_install", false); yyjson_mut_obj_add_str(doc, root, "next_safe_command", "codebase-memory-mcp install -y"); @@ -11568,6 +11853,40 @@ static void uninstall_agent_skill(const char *label, const char *skills_dir, boo printf(" %s skill: %d removed\n", label, removed); } +static void uninstall_codex_skills(const char *home, const char *config_dir, bool dry_run) { + cbm_codex_skill_paths_t paths; + if (!cbm_codex_skill_paths_init(home, config_dir, &paths)) { + if (!dry_run) { + record_agent_config_error(true, "Codex CLI", "skill_path", config_dir); + } + return; + } + + int removed = 0; + if (cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file) == + CBM_CODEX_SKILL_MANAGED) { + if (cbm_remove_codex_managed_skill(paths.canonical_dir, paths.canonical_file, dry_run, true, + "skill_uninstall")) { + removed++; + } + } else { + (void)cbm_remove_codex_managed_skill(paths.canonical_dir, paths.canonical_file, dry_run, + true, "skill_uninstall"); + } + + if (!paths.same_dir && + cbm_codex_skill_state(paths.legacy_dir, paths.legacy_file) == CBM_CODEX_SKILL_MANAGED) { + if (cbm_remove_codex_managed_skill(paths.legacy_dir, paths.legacy_file, dry_run, true, + "legacy_skill_uninstall")) { + removed++; + } + } else if (!paths.same_dir) { + (void)cbm_remove_codex_managed_skill(paths.legacy_dir, paths.legacy_file, dry_run, true, + "legacy_skill_uninstall"); + } + printf(" Codex CLI skills: %d removed\n", removed); +} + static void uninstall_copilot_durable_context(const char *home, bool dry_run) { char config_dir[CLI_BUF_1K]; char hook_path[CLI_BUF_1K]; @@ -11989,13 +12308,11 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char char config_dir[CLI_BUF_1K]; char cp[CLI_BUF_1K]; char ip[CLI_BUF_1K]; - char skills_dir[CLI_BUF_1K]; char ap[CLI_BUF_1K]; char installed_binary[CLI_BUF_1K]; cbm_codex_config_dir(home, config_dir, sizeof(config_dir)); snprintf(cp, sizeof(cp), "%s/config.toml", config_dir); snprintf(ip, sizeof(ip), "%s/AGENTS.md", config_dir); - snprintf(skills_dir, sizeof(skills_dir), "%s/skills", config_dir); snprintf(ap, sizeof(ap), "%s/agents/codebase-memory.toml", config_dir); bool pointer_removed = uninstall_codex_activation_pointer(ip, dry_run); cbm_agent_installed_binary_path(home, installed_binary, sizeof(installed_binary)); @@ -12030,7 +12347,7 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char record_agent_config_error(true, "Codex CLI", "hook_uninstall", cp); } codex_toml_done: - uninstall_agent_skill("Codex CLI", skills_dir, dry_run); + uninstall_codex_skills(home, config_dir, dry_run); uninstall_tiered_agent_profiles( (cbm_tiered_profile_set_t){ .label = "Codex CLI", diff --git a/tests/test_cli.c b/tests/test_cli.c index 06ebb38415..21649fa960 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -6799,7 +6799,6 @@ TEST(cli_durable_profiles_follow_current_vendor_paths) { strstr(plan, "\"instruction_files_planned\""); const char *const planned[] = { "/.claude/agents/codebase-memory.md", - "/vendor-codex/skills/codebase-memory/SKILL.md", "/vendor-codex/agents/codebase-memory.toml", "/.cursor/skills/codebase-memory/SKILL.md", "/.cursor/agents/codebase-memory.md", @@ -6962,13 +6961,12 @@ TEST(cli_durable_profiles_follow_current_vendor_paths) { free(profile); const char *const skill_files[] = { - "/skills/codebase-memory/SKILL.md", "/.cursor/skills/codebase-memory/SKILL.md", "/.config/opencode/skills/codebase-memory/SKILL.md", "/.factory/skills/codebase-memory/SKILL.md", "/.agents/skills/codebase-memory/SKILL.md", }; - const char *const skill_roots[] = {codex_home, tmpdir, tmpdir, tmpdir, tmpdir}; + const char *const skill_roots[] = {tmpdir, tmpdir, tmpdir, tmpdir}; for (size_t i = 0U; files_ok && i < sizeof(skill_files) / sizeof(skill_files[0]); i++) { snprintf(path, sizeof(path), "%s%s", skill_roots[i], skill_files[i]); files_ok = test_file_contains_all(path, graph_terms, 3); @@ -9382,12 +9380,21 @@ TEST(cli_codex_respects_codex_home) { char *json = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp"); char expected_config[640]; snprintf(expected_config, sizeof(expected_config), "%s/config.toml", codex_home); + char expected_skill[640]; + char legacy_skill[640]; + snprintf(expected_skill, sizeof(expected_skill), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + snprintf(legacy_skill, sizeof(legacy_skill), "%s/skills/codebase-memory/SKILL.md", codex_home); yyjson_doc *plan_doc = json ? yyjson_read(json, strlen(json), 0) : NULL; yyjson_val *plan_root = plan_doc ? yyjson_doc_get_root(plan_doc) : NULL; bool plans_config = test_json_string_array_contains(plan_root, "config_files_planned", expected_config); bool plans_instructions = test_json_string_array_contains( plan_root, "instruction_files_planned", expected_instructions); + bool plans_canonical_skill = + test_json_string_array_contains(plan_root, "skill_files_planned", expected_skill); + bool plans_legacy_skill = + test_json_string_array_contains(plan_root, "skill_files_planned", legacy_skill); bool plans_cleanup = json && strstr(json, "remove_managed_block_if_present") != NULL; char *instructions_after = read_test_file_alloc(expected_instructions); bool plan_preserved_user_file = @@ -9401,9 +9408,10 @@ TEST(cli_codex_respects_codex_home) { if (!agents.codex) FAIL("Codex detection must honor CODEX_HOME"); - if (!plans_config || !plans_instructions || plans_cleanup || !plan_preserved_user_file) + if (!plans_config || !plans_instructions || !plans_canonical_skill || plans_legacy_skill || + plans_cleanup || !plan_preserved_user_file) FAIL("Codex plan must include the managed activation pointer under CODEX_HOME without " - "mutating existing user content"); + "mutating existing user content, and use HOME/.agents for the skill"); PASS(); } @@ -9529,7 +9537,6 @@ TEST(cli_codex_install_uses_global_activation_pointer_issue1689) { restore_test_env("PATH", saved_path); restore_test_env("CODEX_HOME", saved_codex); test_rmdir_r(tmpdir); - if (!fresh_pointer_installed || !other_surfaces_installed || !unowned_preserved || !managed_migrated_once || !marker_only_migrated || !malformed_preserved || !uninstall_preserved_foreign) @@ -9581,6 +9588,300 @@ TEST(cli_grok_respects_grok_home) { PASS(); } +static bool cli_test_file_equals(const char *path, const char *expected) { + char *data = read_test_file_alloc(path); + bool equal = data && strcmp(data, expected) == 0; + free(data); + return equal; +} + +TEST(cli_codex_skill_migrates_owned_legacy_to_agents_root) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-migrate-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char legacy_root[640]; + char legacy_file[768]; + char sibling_file[768]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/vendor-codex", tmpdir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(sibling_file, sizeof(sibling_file), "%s/codebase-memory/notes.md", legacy_root); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + test_mkdirp(codex_home); + ASSERT_EQ(cbm_install_skills(legacy_root, false, false), CBM_SKILL_COUNT); + write_test_file(sibling_file, "user notes\n"); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + int dry_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, true); + struct stat state; + bool dry_safe = dry_rc == 0 && stat(canonical_file, &state) != 0 && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); + char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + bool plan_ok = plan && strstr(plan, canonical_file) && strstr(plan, legacy_file) && + strstr(plan, "cleanup_actions_planned") && + strstr(plan, "remove_owned_legacy_copy_if_migrated"); + bool plan_safe = stat(canonical_file, &state) != 0 && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); + free(plan); + + int install_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + bool migrated = + install_rc == 0 && cli_test_file_equals(canonical_file, cbm_get_skills()[0].content) && + stat(legacy_file, &state) != 0 && cli_test_file_equals(sibling_file, "user notes\n"); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + + if (!dry_safe || !plan_ok || !plan_safe || !migrated) + FAIL("Codex owned legacy skill migration must be previewable, destination-first, and " + "sibling-safe"); + PASS(); +} + +TEST(cli_codex_skill_preserves_foreign_legacy_until_force) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-foreign-legacy-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *foreign = "---\nname: codebase-memory\n---\nUser-owned legacy skill.\n"; + char codex_home[512]; + char legacy_dir[768]; + char legacy_file[768]; + char sibling_file[768]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/vendor-codex", tmpdir); + snprintf(legacy_dir, sizeof(legacy_dir), "%s/skills/codebase-memory", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/SKILL.md", legacy_dir); + snprintf(sibling_file, sizeof(sibling_file), "%s/user.txt", legacy_dir); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + test_mkdirp(legacy_dir); + write_test_file(legacy_file, foreign); + write_test_file(sibling_file, "keep\n"); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + int normal_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + struct stat state; + bool preserved = normal_rc == 0 && cli_test_file_equals(legacy_file, foreign) && + stat(canonical_file, &state) != 0; + int force_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", true, false); + bool forced = force_rc == 0 && + cli_test_file_equals(canonical_file, cbm_get_skills()[0].content) && + stat(legacy_file, &state) != 0 && cli_test_file_equals(sibling_file, "keep\n"); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!preserved || !forced) + FAIL("Codex must preserve foreign legacy content unless --force authorizes replacement"); + PASS(); +} + +TEST(cli_codex_skill_preserves_foreign_canonical_and_owned_legacy) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-foreign-canonical-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *foreign = "---\nname: codebase-memory\n---\nUser-owned canonical skill.\n"; + char codex_home[512]; + char legacy_root[640]; + char legacy_file[768]; + char canonical_dir[768]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/vendor-codex", tmpdir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(canonical_dir, sizeof(canonical_dir), "%s/.agents/skills/codebase-memory", tmpdir); + snprintf(canonical_file, sizeof(canonical_file), "%s/SKILL.md", canonical_dir); + test_mkdirp(codex_home); + ASSERT_EQ(cbm_install_skills(legacy_root, false, false), CBM_SKILL_COUNT); + test_mkdirp(canonical_dir); + write_test_file(canonical_file, foreign); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + int normal_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + bool preserved = normal_rc == 0 && cli_test_file_equals(canonical_file, foreign) && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); + int force_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", true, false); + struct stat state; + bool forced = force_rc == 0 && + cli_test_file_equals(canonical_file, cbm_get_skills()[0].content) && + stat(legacy_file, &state) != 0; + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!preserved || !forced) + FAIL("Codex must preserve conflicting canonical bytes and its owned legacy copy until " + "--force"); + PASS(); +} + +TEST(cli_codex_skill_failed_destination_preserves_owned_legacy) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-failed-destination-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char legacy_root[640]; + char legacy_file[768]; + char canonical_root[640]; + char blocked_path[768]; + snprintf(codex_home, sizeof(codex_home), "%s/vendor-codex", tmpdir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(canonical_root, sizeof(canonical_root), "%s/.agents/skills", tmpdir); + snprintf(blocked_path, sizeof(blocked_path), "%s/codebase-memory", canonical_root); + test_mkdirp(codex_home); + ASSERT_EQ(cbm_install_skills(legacy_root, false, false), CBM_SKILL_COUNT); + test_mkdirp(canonical_root); + write_test_file(blocked_path, "not a directory\n"); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + int rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + bool safe = rc != 0 && cli_test_file_equals(blocked_path, "not a directory\n") && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!safe) + FAIL("Codex migration must retain the owned legacy copy when canonical publication fails"); + PASS(); +} + +TEST(cli_codex_skill_uninstall_cleans_both_owned_locations) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-uninstall-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char canonical_root[640]; + char legacy_root[640]; + char canonical_file[768]; + char legacy_file[768]; + char binary_dir[640]; + char binary_path[768]; + snprintf(codex_home, sizeof(codex_home), "%s/.codex", tmpdir); + snprintf(canonical_root, sizeof(canonical_root), "%s/.agents/skills", tmpdir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_home); + snprintf(canonical_file, sizeof(canonical_file), "%s/codebase-memory/SKILL.md", canonical_root); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(binary_dir, sizeof(binary_dir), "%s/.local/bin", tmpdir); +#ifdef _WIN32 + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp.exe", binary_dir); +#else + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp", binary_dir); +#endif + test_mkdirp(codex_home); + test_mkdirp(binary_dir); + write_test_file(binary_path, "installed binary\n"); + ASSERT_EQ(cbm_install_skills(canonical_root, false, false), CBM_SKILL_COUNT); + ASSERT_EQ(cbm_install_skills(legacy_root, false, false), CBM_SKILL_COUNT); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_unsetenv("CODEX_HOME"); + char *argv[] = {"uninstall", "--yes"}; + int owned_rc = cli_test_cmd_uninstall(2, argv); + struct stat state; + bool owned_removed = + owned_rc == 0 && stat(canonical_file, &state) != 0 && stat(legacy_file, &state) != 0; + + const char *foreign = "---\nname: codebase-memory\n---\nUser-owned canonical skill.\n"; + write_test_file(binary_path, "installed binary\n"); + bool mixed_seeded = cbm_install_skills(canonical_root, false, false) == CBM_SKILL_COUNT && + cbm_install_skills(legacy_root, false, false) == CBM_SKILL_COUNT; + if (mixed_seeded) { + write_test_file(canonical_file, foreign); + } + int mixed_rc = mixed_seeded ? cli_test_cmd_uninstall(2, argv) : 1; + bool mixed_safe = mixed_rc == 0 && cli_test_file_equals(canonical_file, foreign) && + stat(legacy_file, &state) != 0; + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!owned_removed || !mixed_safe) + FAIL("Codex uninstall must clean both owned roots while preserving foreign skill bytes"); + PASS(); +} + +TEST(cli_codex_skill_deduplicates_agents_codex_home) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-same-root-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char skill_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/.agents", tmpdir); + snprintf(skill_file, sizeof(skill_file), "%s/skills/codebase-memory/SKILL.md", codex_home); + test_mkdirp(codex_home); + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + int install_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + bool deduplicated = install_rc == 0 && + cli_test_file_equals(skill_file, cbm_get_skills()[0].content) && plan && + !strstr(plan, "remove_owned_legacy_copy_if_migrated"); + free(plan); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!deduplicated) + FAIL("Codex must treat HOME/.agents and CODEX_HOME as one skill root when they coincide"); + PASS(); +} + TEST(cli_gemini_session_hook_uses_json_for_all_sources) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-gemini-session-XXXXXX"); @@ -11761,7 +12062,7 @@ TEST(cli_codex_migrates_to_single_hook_representation) { int uninstall_rc = cli_test_cmd_uninstall(1, uninstall_argv); char skill_path[768]; char agent_path[768]; - snprintf(skill_path, sizeof(skill_path), "%s/skills/codebase-memory/SKILL.md", codex_dir); + snprintf(skill_path, sizeof(skill_path), "%s/.agents/skills/codebase-memory/SKILL.md", tmpdir); snprintf(agent_path, sizeof(agent_path), "%s/agents/codebase-memory.toml", codex_dir); struct stat state; hooks = read_test_file_alloc(hooks_path); @@ -11908,9 +12209,16 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { char codex_dir[512]; char config_path[640]; char agents_path[640]; + char legacy_root[640]; + char legacy_file[768]; + char canonical_file[768]; snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_dir); snprintf(agents_path, sizeof(agents_path), "%s/AGENTS.md", codex_dir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_dir); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); if (test_mkdirp(codex_dir) != 0) { test_rmdir_r(tmpdir); FAIL("failed to create Codex preflight fixture directory"); @@ -11923,6 +12231,10 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { test_rmdir_r(tmpdir); FAIL("failed to write Codex preflight fixture config"); } + if (cbm_install_skills(legacy_root, false, false) != CBM_SKILL_COUNT) { + test_rmdir_r(tmpdir); + FAIL("failed to seed owned legacy Codex skill"); + } char *saved_home = save_test_env("HOME"); char *saved_path = save_test_env("PATH"); @@ -11964,8 +12276,11 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { } char *after = read_test_file_alloc(config_path); char *agents_after = read_test_file_alloc(agents_path); + struct stat state; bool unchanged = after && strcmp(after, ambiguous) == 0 && agents_after && - strcmp(agents_after, test_codex_activation_block) == 0; + strcmp(agents_after, test_codex_activation_block) == 0 && + stat(canonical_file, &state) != 0 && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); bool diagnostic = strstr(output, "Codex CLI:\nerror: agent_config agent=Codex CLI op=hook_preflight path=") != NULL && @@ -16175,6 +16490,12 @@ SUITE(cli) { RUN_TEST(cli_codex_respects_codex_home); RUN_TEST(cli_grok_respects_grok_home); RUN_TEST(cli_codex_install_uses_global_activation_pointer_issue1689); + RUN_TEST(cli_codex_skill_migrates_owned_legacy_to_agents_root); + RUN_TEST(cli_codex_skill_preserves_foreign_legacy_until_force); + RUN_TEST(cli_codex_skill_preserves_foreign_canonical_and_owned_legacy); + RUN_TEST(cli_codex_skill_failed_destination_preserves_owned_legacy); + RUN_TEST(cli_codex_skill_uninstall_cleans_both_owned_locations); + RUN_TEST(cli_codex_skill_deduplicates_agents_codex_home); RUN_TEST(cli_gemini_session_hook_uses_json_for_all_sources); RUN_TEST(cli_gemini_installs_dedicated_graph_subagent); RUN_TEST(cli_antigravity_does_not_imply_gemini); From b961919223704bfb3e38adde55fab5651a2ca4e0 Mon Sep 17 00:00:00 2001 From: Benyamin Date: Fri, 21 Aug 2026 15:27:24 +0330 Subject: [PATCH 11/97] fix(cli): simplify Codex cleanup condition Signed-off-by: Benyamin --- src/cli/cli.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index 8401179ab5..3aaadb01ee 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -8746,7 +8746,7 @@ static bool cbm_remove_codex_managed_skill(const char *skills_dir, const char *s printf(" preserved non-owned Codex skill: %s\n", skill_file); } else if (dry_run) { printf(" preserved unsafe Codex skill path: %s\n", skill_file); - } else if (!dry_run) { + } else { record_agent_config_error(uninstalling, "Codex CLI", operation, skill_file); } return state == CBM_CODEX_SKILL_UNOWNED; From 7c88e64eb954e9400273d3ebb9f6f88f1aebc165 Mon Sep 17 00:00:00 2001 From: Benyamin Date: Tue, 15 Sep 2026 11:20:19 +0330 Subject: [PATCH 12/97] fix(codex): recognize released skill payloads Signed-off-by: Benyamin --- scripts/smoke-test.sh | 25 +++- src/cli/cli.c | 314 ++++++++++++++++++++++++++++++++++++------ src/cli/cli.h | 5 + tests/test_cli.c | 114 ++++++++++++++- 4 files changed, 413 insertions(+), 45 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 620e73c66e..198e81d339 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1395,11 +1395,21 @@ if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex \ cat "$CODEX_ONLY_LOG" exit 1 fi +mkdir -p "$CODEX_ONLY_HOME/.openhands" +if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex,openhands \ + --dir="$CODEX_ONLY_INSTALL" || + ! grep -q '^name: codebase-memory$' "$CODEX_ONLY_SKILL" 2>/dev/null || + [ "$(smoke_file_sha256 "$CODEX_ONLY_LEGACY")" != "$CODEX_ONLY_FOREIGN_HASH" ]; then + echo "FAIL 8-codex-5: a foreign Codex legacy skill blocked a shared-client install" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +rm -f "$CODEX_ONLY_SKILL" if ! run_codex_only "$CODEX_ONLY_BIN" install --force -y --clients=codex \ --dir="$CODEX_ONLY_INSTALL" || ! grep -q '^name: codebase-memory$' "$CODEX_ONLY_SKILL" 2>/dev/null || [ -e "$CODEX_ONLY_LEGACY" ]; then - echo "FAIL 8-codex-5: force did not converge a foreign legacy skill" + echo "FAIL 8-codex-6: force did not converge a foreign legacy skill" cat "$CODEX_ONLY_LOG" exit 1 fi @@ -1407,7 +1417,7 @@ CODEX_ONLY_HASH=$(smoke_file_sha256 "$CODEX_ONLY_SKILL") if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex \ --dir="$CODEX_ONLY_INSTALL" || [ "$(smoke_file_sha256 "$CODEX_ONLY_SKILL")" != "$CODEX_ONLY_HASH" ]; then - echo "FAIL 8-codex-6: repeated Codex install was not idempotent" + echo "FAIL 8-codex-7: repeated Codex install was not idempotent" cat "$CODEX_ONLY_LOG" exit 1 fi @@ -1826,6 +1836,8 @@ fi CODEX_LIFECYCLE_HOME=$(smoke_mktemp_dir) CODEX_LIFECYCLE_ROOT="$CODEX_LIFECYCLE_HOME/.codex" CODEX_INSTRUCTIONS="$CODEX_LIFECYCLE_ROOT/AGENTS.md" +CODEX_LIFECYCLE_SKILL="$CODEX_LIFECYCLE_HOME/.agents/skills/codebase-memory/SKILL.md" +CODEX_LIFECYCLE_LEGACY_SKILL="$CODEX_LIFECYCLE_ROOT/skills/codebase-memory/SKILL.md" mkdir -p "$CODEX_LIFECYCLE_ROOT" CODEX_FRESH_LOG=$(smoke_mktemp_file) @@ -1839,7 +1851,8 @@ if [ ! -f "$CODEX_INSTRUCTIONS" ] || [ "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" != \ "$(smoke_file_sha256 "$CODEX_POINTER_EXPECTED")" ] || ! grep -q '\[mcp_servers.codebase-memory-mcp\]' "$CODEX_LIFECYCLE_ROOT/config.toml" || - ! grep -q 'search_graph' "$CODEX_LIFECYCLE_ROOT/skills/codebase-memory/SKILL.md" || + ! grep -q 'search_graph' "$CODEX_LIFECYCLE_SKILL" || + [ -e "$CODEX_LIFECYCLE_LEGACY_SKILL" ] || [ ! -s "$CODEX_LIFECYCLE_ROOT/agents/codebase-memory-scout.toml" ] || [ ! -s "$CODEX_LIFECYCLE_ROOT/agents/codebase-memory.toml" ] || [ ! -s "$CODEX_LIFECYCLE_ROOT/agents/codebase-memory-auditor.toml" ] || @@ -1936,8 +1949,10 @@ if ! HOME="$CODEX_LIFECYCLE_HOME" \ fi if [ ! -f "$CODEX_INSTRUCTIONS" ] || [ "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" != \ - "$(smoke_file_sha256 "$CODEX_EXPECTED_USER")" ]; then - echo "FAIL 8i: Codex uninstall did not remove only the activation pointer" + "$(smoke_file_sha256 "$CODEX_EXPECTED_USER")" ] || + [ -e "$CODEX_LIFECYCLE_SKILL" ] || + [ -e "$CODEX_LIFECYCLE_LEGACY_SKILL" ]; then + echo "FAIL 8i: Codex uninstall did not remove only installer-owned surfaces" exit 1 fi echo "OK 8i: isolated Codex activation-pointer lifecycle" diff --git a/src/cli/cli.c b/src/cli/cli.c index 3aaadb01ee..dea1a26fb4 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1421,6 +1421,232 @@ int cbm_replace_binary(const char *path, const unsigned char *data, int len, int /* Consolidated from 4 separate skills into 1 with progressive disclosure. * This embedded version is the single source of truth for the CLI installer. * Based on PR #81 by @gdilla — factual corrections applied. */ +static const char released_skill_content_v0_6_0[] = + "---\n" + "name: codebase-memory\n" + "description: Use the codebase knowledge graph for structural code queries. " + "Triggers on: explore the codebase, understand the architecture, what functions exist, " + "show me the structure, who calls this function, what does X call, trace the call chain, " + "find callers of, show dependencies, impact analysis, dead code, unused functions, " + "high fan-out, refactor candidates, code quality audit, graph query syntax, " + "Cypher query examples, edge types, how to use search_graph.\n" + "---\n" + "\n" + "# Codebase Memory — Knowledge Graph Tools\n" + "\n" + "Graph tools return precise structural results in ~500 tokens vs ~80K for grep.\n" + "\n" + "## Quick Decision Matrix\n" + "\n" + "| Question | Tool call |\n" + "|----------|----------|\n" + "| Who calls X? | `trace_path(direction=\"inbound\")` |\n" + "| What does X call? | `trace_path(direction=\"outbound\")` |\n" + "| Full call context | `trace_path(direction=\"both\")` |\n" + "| Find by name pattern | `search_graph(name_pattern=\"...\")` |\n" + "| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` |\n" + "| Cross-service edges | `query_graph` with Cypher |\n" + "| Impact of local changes | `detect_changes()` |\n" + "| Risk-classified trace | `trace_path(risk_labels=true)` |\n" + "| Text search | `search_code` or Grep |\n" + "\n" + "## Exploration Workflow\n" + "1. `list_projects` — check if project is indexed\n" + "2. `get_graph_schema` — understand node/edge types\n" + "3. `search_graph(label=\"Function\", name_pattern=\".*Pattern.*\")` — find code\n" + "4. `get_code_snippet(qualified_name=\"project.path.FuncName\")` — read source\n" + "\n" + "## Tracing Workflow\n" + "1. `search_graph(name_pattern=\".*FuncName.*\")` — discover exact name\n" + "2. `trace_path(function_name=\"FuncName\", direction=\"both\", depth=3)` — trace\n" + "3. `detect_changes()` — map git diff to affected symbols\n" + "\n" + "## Quality Analysis\n" + "- Dead code: `search_graph(max_degree=0, exclude_entry_points=true)`\n" + "- High fan-out: `search_graph(min_degree=10, relationship=\"CALLS\", " + "direction=\"outbound\")`\n" + "- High fan-in: `search_graph(min_degree=10, relationship=\"CALLS\", " + "direction=\"inbound\")`\n" + "\n" + "## 14 MCP Tools\n" + "`index_repository`, `index_status`, `list_projects`, `delete_project`,\n" + "`search_graph`, `search_code`, `trace_path`, `detect_changes`,\n" + "`query_graph`, `get_graph_schema`, `get_code_snippet`, `get_architecture`,\n" + "`manage_adr`, `ingest_traces`\n" + "\n" + "## Edge Types\n" + "CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD,\n" + "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH,\n" + "CONTAINS_FILE, CONTAINS_FOLDER, CONTAINS_PACKAGE\n" + "\n" + "## Cypher Examples (for query_graph)\n" + "```\n" + "MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, " + "r.confidence LIMIT 20\n" + "MATCH (f:Function) WHERE f.name =~ '.*Handler.*' RETURN f.name, f.file_path\n" + "MATCH (a)-[r:CALLS]->(b) WHERE a.name = 'main' RETURN b.name\n" + "```\n" + "\n" + "## Gotchas\n" + "1. `search_graph(relationship=\"HTTP_CALLS\")` filters nodes by degree — " + "use `query_graph` with Cypher to see actual edges.\n" + "2. `query_graph` has a 200-row cap — use `search_graph` with degree filters " + "for counting.\n" + "3. `trace_path` needs exact names — use `search_graph(name_pattern=...)` first.\n" + "4. `direction=\"outbound\"` misses cross-service callers — use " + "`direction=\"both\"`.\n" + "5. Results default to 10 per page — check `has_more` and use `offset`.\n"; + +#define CBM_RELEASED_SKILL_HEADER_UNQUOTED \ + "---\n" \ + "name: codebase-memory\n" \ + "description: Use the codebase knowledge graph for structural code queries. " \ + "Triggers on: explore the codebase, understand the architecture, what functions exist, " \ + "show me the structure, who calls this function, what does X call, trace the call chain, " \ + "find callers of, show dependencies, impact analysis, dead code, unused functions, " \ + "high fan-out, refactor candidates, code quality audit, graph query syntax, " \ + "Cypher query examples, edge types, how to use search_graph.\n" \ + "---\n" + +#define CBM_RELEASED_SKILL_HEADER_QUOTED \ + "---\n" \ + "name: codebase-memory\n" \ + "description: \"Use the codebase knowledge graph for structural code queries. " \ + "Triggers on: explore the codebase, understand the architecture, what functions exist, " \ + "show me the structure, who calls this function, what does X call, trace the call chain, " \ + "find callers of, show dependencies, impact analysis, dead code, unused functions, " \ + "high fan-out, refactor candidates, code quality audit, graph query syntax, " \ + "Cypher query examples, edge types, how to use search_graph.\"\n" \ + "---\n" + +#define CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES \ + "\n" \ + "# Codebase Memory — Knowledge Graph Tools\n" \ + "\n" \ + "Graph tools return precise structural results in ~500 tokens vs ~80K for grep.\n" \ + "\n" \ + "## Quick Decision Matrix\n" \ + "\n" \ + "| Question | Tool call |\n" \ + "|----------|----------|\n" \ + "| Who calls X? | `trace_path(direction=\"inbound\")` |\n" \ + "| What does X call? | `trace_path(direction=\"outbound\")` |\n" \ + "| Full call context | `trace_path(direction=\"both\")` |\n" \ + "| Find by name pattern | `search_graph(name_pattern=\"...\")` |\n" \ + "| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` |\n" \ + "| Cross-service edges | `query_graph` with Cypher |\n" \ + "| Impact of local changes | `detect_changes()` |\n" \ + "| Risk-classified trace | `trace_path(risk_labels=true)` |\n" \ + "| Text search | `search_code` or Grep |\n" \ + "\n" \ + "## Exploration Workflow\n" \ + "1. `list_projects` — check if project is indexed\n" \ + "2. `get_graph_schema` — understand node/edge types\n" \ + "3. `search_graph(label=\"Function\", name_pattern=\".*Pattern.*\")` — find code\n" \ + "4. `get_code_snippet(qualified_name=\"project.path.FuncName\")` — read source\n" \ + "\n" \ + "## Tracing Workflow\n" \ + "1. `search_graph(name_pattern=\".*FuncName.*\")` — discover exact name\n" \ + "2. `trace_path(function_name=\"FuncName\", direction=\"both\", depth=3)` — trace\n" \ + "3. `detect_changes()` — map git diff to affected symbols\n" \ + "\n" \ + "## Evidence Tiers\n" \ + "- **Scout (Tier 1):** fast positive lookup with few graph calls and targeted source checks. " \ + "Treat results as provisional; never make absence, exhaustive, dead-code, or complete-impact " \ + "claims.\n" \ + "- **Verify (Tier 2, default):** task-directed searches, relevant trace directions, exact " \ + "snippets for material claims, and all relevant result pages.\n" \ + "- **Auditor (Tier 3):** bounded-scope full verification with a current graph generation, " \ + "complete relevant pagination, both call directions and broader relationships when material, " \ + "plus explicit unresolved limitations.\n" \ + "- **Every tier:** after candidate paths are known, call `check_index_coverage` once with " \ + "every " \ + "evidence path. For negative or exhaustive claims also include the relevant scopes. A clean " \ + "result means no recorded gap, not proof of completeness. For partial, skipped, excluded, " \ + "stale, pending, or unknown coverage, read/grep the reported ranges or scope before relying " \ + "on " \ + "the graph.\n" \ + "\n" \ + "## Sessions and Subagents\n" \ + "- At session start or after compaction, call `list_projects`/`index_status` before " \ + "structural exploration, then choose Scout, Verify, or Auditor for the task.\n" \ + "- Before delegating, query the graph and coverage in the parent. Pass the tier, exact " \ + "project, " \ + "generation/freshness, bounded scope, queries and pagination state, qualified symbols, " \ + "paths, " \ + "call-chain findings, coverage ranges/reasons, source fallback already performed, and " \ + "unresolved " \ + "questions to the child.\n" \ + "- Runtimes such as Hermes isolate child context: put those graph findings in the " \ + "`context` argument to `delegate_task`; do not assume the child inherits MCP access or " \ + "the parent's conversation.\n" \ + "- A child without MCP tools must not call or claim MCP access. It should work from the " \ + "supplied " \ + "evidence and use read/grep on exact source, especially every reported missed-coverage " \ + "range.\n" \ + "\n" \ + "## Quality Analysis\n" \ + "- Dead code: `search_graph(max_degree=0, exclude_entry_points=true)`\n" \ + "- High fan-out: `search_graph(min_degree=10, relationship=\"CALLS\", " \ + "direction=\"outbound\")`\n" \ + "- High fan-in: `search_graph(min_degree=10, relationship=\"CALLS\", " \ + "direction=\"inbound\")`\n" \ + "\n" \ + "## 15 MCP Tools\n" \ + "`index_repository`, `index_status`, `list_projects`, `delete_project`,\n" \ + "`search_graph`, `search_code`, `trace_path`, `detect_changes`,\n" \ + "`query_graph`, `get_graph_schema`, `get_code_snippet`, `get_architecture`,\n" \ + "`check_index_coverage`, `manage_adr`, `ingest_traces`\n" \ + "\n" \ + "## Edge Types\n" \ + "CALLS, HTTP_CALLS, ASYNC_CALLS, DATA_FLOWS, IMPORTS, DEFINES, DEFINES_METHOD,\n" + +#define CBM_RELEASED_SKILL_EDGE_TYPES_PRE_V0_10 \ + "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, CONFIGURES, FILE_CHANGES_WITH,\n" + +#define CBM_RELEASED_SKILL_EDGE_TYPES_V0_10 \ + "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, CALL_REFERENCE, CONFIGURES, FILE_CHANGES_WITH,\n" + +#define CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES \ + "SIMILAR_TO, SEMANTICALLY_RELATED, CONTAINS_FILE, CONTAINS_FOLDER,\n" \ + "CONTAINS_PACKAGE\n" \ + "\n" \ + "## Cypher Examples (for query_graph)\n" \ + "```\n" \ + "MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, " \ + "r.confidence LIMIT 20\n" \ + "MATCH (f:Function) WHERE f.name =~ '.*Handler.*' RETURN f.name, f.file_path\n" \ + "MATCH (a)-[r:CALLS]->(b) WHERE a.name = 'main' RETURN b.name\n" \ + "```\n" \ + "\n" \ + "## Gotchas\n" \ + "1. `search_graph(relationship=\"HTTP_CALLS\")` filters nodes by degree — " \ + "use `query_graph` with Cypher to see actual edges.\n" \ + "2. `query_graph` has a 100k row ceiling — add a Cypher `LIMIT` for broad queries " \ + "or use `search_graph` pagination.\n" \ + "3. `trace_path` needs exact names — use `search_graph(name_pattern=...)` first.\n" \ + "4. `direction=\"outbound\"` misses cross-service callers — use " \ + "`direction=\"both\"`.\n" \ + "5. `search_graph` results default to 50 per page — check `has_more` and use `offset`.\n" + +/* Exact payload generations shipped before the current skill text. */ +static const char released_skill_content_v0_9_1[] = + CBM_RELEASED_SKILL_HEADER_UNQUOTED CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES + CBM_RELEASED_SKILL_EDGE_TYPES_PRE_V0_10 CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES; +static const char released_skill_content_v0_10_0[] = + CBM_RELEASED_SKILL_HEADER_UNQUOTED CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES + CBM_RELEASED_SKILL_EDGE_TYPES_V0_10 CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES; +static const char released_skill_content_v0_10_3[] = + CBM_RELEASED_SKILL_HEADER_QUOTED CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES + CBM_RELEASED_SKILL_EDGE_TYPES_V0_10 CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES; + +#undef CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES +#undef CBM_RELEASED_SKILL_EDGE_TYPES_V0_10 +#undef CBM_RELEASED_SKILL_EDGE_TYPES_PRE_V0_10 +#undef CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES +#undef CBM_RELEASED_SKILL_HEADER_QUOTED +#undef CBM_RELEASED_SKILL_HEADER_UNQUOTED + static const char skill_content[] = "---\n" "name: codebase-memory\n" @@ -1535,6 +1761,19 @@ static const char skill_content[] = "`direction=\"both\"`.\n" "5. `search_graph` results default to 50 per page — check `has_more` and use `offset`.\n"; +/* Byte-exact consolidated skill payloads shipped by each release range. */ +static const char *const released_skill_contents[] = { + /* 0.6.0 through v0.9.0 */ + released_skill_content_v0_6_0, + /* v0.9.1-rc.1 */ + released_skill_content_v0_9_1, + /* v0.10.0 through v0.10.2 */ + released_skill_content_v0_10_0, + /* v0.10.3 through v0.10.8 */ + released_skill_content_v0_10_3, +}; +enum { RELEASED_SKILL_CONTENT_COUNT = 4 }; + static const char codex_instructions_content[] = "For structural codebase exploration, use the installed `codebase-memory` skill.\n"; @@ -1555,6 +1794,16 @@ const cbm_skill_t *cbm_get_skills(void) { return skills; } +#ifdef CBM_CLI_ENABLE_TEST_API +size_t cbm_cli_released_skill_content_count(void) { + return RELEASED_SKILL_CONTENT_COUNT; +} + +const char *cbm_cli_released_skill_content_at(size_t index) { + return index < RELEASED_SKILL_CONTENT_COUNT ? released_skill_contents[index] : NULL; +} +#endif + const char *cbm_get_codex_instructions(void) { return codex_instructions_content; } @@ -8125,7 +8374,6 @@ typedef struct { static cbm_install_plan_t *g_install_plan = NULL; static int g_agent_install_errors = 0; static int g_agent_uninstall_errors = 0; -static char g_blocked_shared_skill_dir[CLI_BUF_1K]; /* Every agent configuration uninstall could not clean, kept for the closing * summary. A cleanup failure no longer stops executable and index removal @@ -8611,14 +8859,6 @@ static void install_agent_skill(const char *label, const char *skills_dir, bool if (written < 0 || (size_t)written >= sizeof(skill_path)) { return; } - if (g_blocked_shared_skill_dir[0] && - (cbm_json_mcp_paths_equal(skills_dir, g_blocked_shared_skill_dir) || - cbm_same_file(skills_dir, g_blocked_shared_skill_dir))) { - if (!g_install_plan) { - printf(" skill: %s (skipped to avoid a duplicate Codex skill)\n", skill_path); - } - return; - } if (g_install_plan) { plan_record(label, "skill", skill_path); return; @@ -8702,7 +8942,8 @@ static cbm_codex_skill_state_t cbm_codex_skill_state(const char *skills_dir, return CBM_CODEX_SKILL_UNSAFE; } - int ownership = cbm_text_owned_document_status(skill_file, skill_content, NULL, 0); + int ownership = cbm_text_owned_document_status( + skill_file, skill_content, released_skill_contents, RELEASED_SKILL_CONTENT_COUNT); if (ownership == CLI_OK) { return CBM_CODEX_SKILL_MANAGED; } @@ -8716,23 +8957,18 @@ static void cbm_warn_codex_skill_conflict(const cbm_codex_skill_paths_t *paths, reason, paths->canonical_file, paths->legacy_file); } -static void cbm_block_shared_skill_install(const cbm_codex_skill_paths_t *paths) { - (void)snprintf(g_blocked_shared_skill_dir, sizeof(g_blocked_shared_skill_dir), "%s", - paths->canonical_dir); -} - -static void cbm_block_shared_skill_if_codex_conflicts(const char *home, const char *config_dir) { - cbm_codex_skill_paths_t paths; - if (!cbm_codex_skill_paths_init(home, config_dir, &paths) || paths.same_dir) { - return; +static bool cbm_refresh_codex_managed_skill(const cbm_codex_skill_paths_t *paths, bool dry_run) { + if (dry_run) { + return true; } - - cbm_codex_skill_state_t canonical = - cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file); - cbm_codex_skill_state_t legacy = cbm_codex_skill_state(paths.legacy_dir, paths.legacy_file); - if (canonical == CBM_CODEX_SKILL_ABSENT && legacy != CBM_CODEX_SKILL_ABSENT) { - cbm_block_shared_skill_install(&paths); + int result = + cbm_text_migrate_owned_document(paths->canonical_file, skill_content, + released_skill_contents, RELEASED_SKILL_CONTENT_COUNT); + if (result == CLI_OK) { + return true; } + record_agent_config_error(false, "Codex CLI", "skill_install", paths->canonical_file); + return false; } static bool cbm_remove_codex_managed_skill(const char *skills_dir, const char *skill_file, @@ -8755,9 +8991,15 @@ static bool cbm_remove_codex_managed_skill(const char *skills_dir, const char *s printf(" Codex skill: %s (would remove)\n", skill_file); return true; } - int removed = cbm_remove_skills(skills_dir, false); - if (removed == CBM_SKILL_COUNT && + int removed = cbm_text_remove_owned_document_any( + skill_file, skill_content, released_skill_contents, RELEASED_SKILL_CONTENT_COUNT); + if (removed == CLI_OK && cbm_codex_skill_state(skills_dir, skill_file) == CBM_CODEX_SKILL_ABSENT) { + char skill_dir[CLI_BUF_1K]; + int written = snprintf(skill_dir, sizeof(skill_dir), "%s/codebase-memory", skills_dir); + if (written > 0 && (size_t)written < sizeof(skill_dir)) { + (void)cbm_remove_empty_directory(skill_dir, false); + } return true; } record_agent_config_error(uninstalling, "Codex CLI", operation, skill_file); @@ -8789,10 +9031,6 @@ static void install_codex_skill(const char *home, const char *config_dir, bool f (canonical == CBM_CODEX_SKILL_ABSENT || canonical == CBM_CODEX_SKILL_MANAGED)) { plan_record("Codex CLI", "cleanup_skill", paths.legacy_file); } - if (canonical == CBM_CODEX_SKILL_ABSENT && - (legacy == CBM_CODEX_SKILL_UNOWNED || legacy == CBM_CODEX_SKILL_UNSAFE)) { - cbm_block_shared_skill_install(&paths); - } return; } @@ -8806,6 +9044,9 @@ static void install_codex_skill(const char *home, const char *config_dir, bool f return; } install_agent_skill("Codex CLI", paths.canonical_dir, force, dry_run); + if (!cbm_refresh_codex_managed_skill(&paths, dry_run)) { + return; + } if (!dry_run && cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file) != CBM_CODEX_SKILL_MANAGED) { record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); @@ -8822,9 +9063,6 @@ static void install_codex_skill(const char *home, const char *config_dir, bool f return; } if (legacy == CBM_CODEX_SKILL_UNSAFE) { - if (canonical == CBM_CODEX_SKILL_ABSENT) { - cbm_block_shared_skill_install(&paths); - } if (force && !dry_run) { record_agent_config_error(false, "Codex CLI", "legacy_skill_migration", paths.legacy_file); @@ -8835,9 +9073,6 @@ static void install_codex_skill(const char *home, const char *config_dir, bool f } if (!force && legacy == CBM_CODEX_SKILL_UNOWNED) { - if (canonical == CBM_CODEX_SKILL_ABSENT) { - cbm_block_shared_skill_install(&paths); - } cbm_warn_codex_skill_conflict(&paths, "the legacy skill is not installer-owned"); return; } @@ -8882,6 +9117,9 @@ static void install_codex_skill(const char *home, const char *config_dir, bool f } return; } + if (!cbm_refresh_codex_managed_skill(&paths, false)) { + return; + } if (cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file) != CBM_CODEX_SKILL_MANAGED) { record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); @@ -9801,7 +10039,6 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const true, &preflight_failure) : CLI_ERR; if (preflight_result != CLI_OK) { - cbm_block_shared_skill_if_codex_conflicts(home, config_dir); if (!g_install_plan) { printf("Codex CLI:\n"); fflush(stdout); @@ -10606,7 +10843,6 @@ static int cbm_install_agent_configs_in_scope(const char *home, const char *bina int cbm_install_agent_configs(const char *home, const char *binary_path, bool force, bool dry_run) { g_agent_install_errors = 0; - g_blocked_shared_skill_dir[0] = '\0'; cbm_detected_agents_t agents = cbm_detect_agents(home); if (g_client_selection && !cli_clients_apply_selection(g_client_selection, &agents)) { return CLI_ERR; diff --git a/src/cli/cli.h b/src/cli/cli.h index 139221f0a3..9f5c49aa2e 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -108,6 +108,11 @@ typedef struct { /* Get the array of skill definitions. */ const cbm_skill_t *cbm_get_skills(void); +#ifdef CBM_CLI_ENABLE_TEST_API +size_t cbm_cli_released_skill_content_count(void); +const char *cbm_cli_released_skill_content_at(size_t index); +#endif + /* Install skills to skills_dir (e.g. ~/.claude/skills/). * If force is true, overwrite existing skills. * Returns count of skills written. */ diff --git a/tests/test_cli.c b/tests/test_cli.c index 21649fa960..3a48ebe2ee 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -9430,7 +9430,7 @@ TEST(cli_codex_install_uses_global_activation_pointer_issue1689) { snprintf(codex_home, sizeof(codex_home), "%s/.codex", tmpdir); snprintf(agents_path, sizeof(agents_path), "%s/AGENTS.md", codex_home); snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_home); - snprintf(skill_path, sizeof(skill_path), "%s/skills/codebase-memory/SKILL.md", codex_home); + snprintf(skill_path, sizeof(skill_path), "%s/.agents/skills/codebase-memory/SKILL.md", tmpdir); snprintf(profile_path, sizeof(profile_path), "%s/agents/codebase-memory.toml", codex_home); #ifdef _WIN32 snprintf(binary_path, sizeof(binary_path), "%s/.local/bin/codebase-memory-mcp.exe", tmpdir); @@ -9651,6 +9651,112 @@ TEST(cli_codex_skill_migrates_owned_legacy_to_agents_root) { PASS(); } +TEST(cli_codex_skill_migrates_every_released_legacy_payload) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-released-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char legacy_dir[768]; + char legacy_file[768]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/.codex", tmpdir); + snprintf(legacy_dir, sizeof(legacy_dir), "%s/skills/codebase-memory", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/SKILL.md", legacy_dir); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + test_mkdirp(codex_home); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + cbm_cli_set_client_selection_for_testing("codex"); + + size_t released_count = cbm_cli_released_skill_content_count(); + bool migrated_all = released_count == 4U; + for (size_t i = 0U; migrated_all && i < released_count; i++) { + const char *released = cbm_cli_released_skill_content_at(i); + test_mkdirp(legacy_dir); + if (!released || write_test_file(legacy_file, released) != 0) { + migrated_all = false; + break; + } + (void)remove(canonical_file); + int rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + struct stat state; + migrated_all = rc == 0 && + cli_test_file_equals(canonical_file, cbm_get_skills()[0].content) && + stat(legacy_file, &state) != 0; + if (!migrated_all || write_test_file(canonical_file, released) != 0) { + migrated_all = false; + break; + } + rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + migrated_all = rc == 0 && cli_test_file_equals(canonical_file, cbm_get_skills()[0].content); + } + + cbm_cli_set_client_selection_for_testing(NULL); + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!migrated_all) + FAIL("Codex must migrate every released skill payload from legacy and canonical paths"); + PASS(); +} + +TEST(cli_codex_foreign_legacy_does_not_block_shared_clients) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-shared-client-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *foreign = "---\nname: codebase-memory\n---\nUser-owned legacy skill.\n"; + char codex_home[512]; + char legacy_dir[768]; + char legacy_file[768]; + char openhands_dir[512]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/.codex", tmpdir); + snprintf(legacy_dir, sizeof(legacy_dir), "%s/skills/codebase-memory", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/SKILL.md", legacy_dir); + snprintf(openhands_dir, sizeof(openhands_dir), "%s/.openhands", tmpdir); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + test_mkdirp(legacy_dir); + test_mkdirp(openhands_dir); + write_test_file(legacy_file, foreign); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + bool plan_keeps_shared_install = plan && strstr(plan, canonical_file) && + !strstr(plan, "remove_owned_legacy_copy_if_migrated"); + free(plan); + int install_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + int repeat_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + bool installed = install_rc == 0 && repeat_rc == 0 && + cli_test_file_equals(legacy_file, foreign) && + cli_test_file_equals(canonical_file, cbm_get_skills()[0].content); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!plan_keeps_shared_install || !installed) + FAIL("A foreign Codex legacy skill must not suppress another client's shared skill"); + PASS(); +} + TEST(cli_codex_skill_preserves_foreign_legacy_until_force) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-foreign-legacy-XXXXXX"); @@ -9679,6 +9785,7 @@ TEST(cli_codex_skill_preserves_foreign_legacy_until_force) { cbm_setenv("HOME", tmpdir, 1); cbm_setenv("PATH", tmpdir, 1); cbm_setenv("CODEX_HOME", codex_home, 1); + cbm_cli_set_client_selection_for_testing("codex"); int normal_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); struct stat state; @@ -9689,6 +9796,7 @@ TEST(cli_codex_skill_preserves_foreign_legacy_until_force) { cli_test_file_equals(canonical_file, cbm_get_skills()[0].content) && stat(legacy_file, &state) != 0 && cli_test_file_equals(sibling_file, "keep\n"); + cbm_cli_set_client_selection_for_testing(NULL); restore_test_env("HOME", saved_home); restore_test_env("PATH", saved_path); restore_test_env("CODEX_HOME", saved_codex); @@ -12242,6 +12350,7 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { cbm_setenv("HOME", tmpdir, 1); cbm_setenv("PATH", tmpdir, 1); cbm_unsetenv("CODEX_HOME"); + cbm_cli_set_client_selection_for_testing("codex"); FILE *capture = tmpfile(); int saved_stdout = capture ? dup(STDOUT_FILENO) : -1; @@ -12288,6 +12397,7 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { free(after); free(agents_after); + cbm_cli_set_client_selection_for_testing(NULL); restore_test_env("HOME", saved_home); restore_test_env("PATH", saved_path); restore_test_env("CODEX_HOME", saved_codex); @@ -16491,6 +16601,8 @@ SUITE(cli) { RUN_TEST(cli_grok_respects_grok_home); RUN_TEST(cli_codex_install_uses_global_activation_pointer_issue1689); RUN_TEST(cli_codex_skill_migrates_owned_legacy_to_agents_root); + RUN_TEST(cli_codex_skill_migrates_every_released_legacy_payload); + RUN_TEST(cli_codex_foreign_legacy_does_not_block_shared_clients); RUN_TEST(cli_codex_skill_preserves_foreign_legacy_until_force); RUN_TEST(cli_codex_skill_preserves_foreign_canonical_and_owned_legacy); RUN_TEST(cli_codex_skill_failed_destination_preserves_owned_legacy); From 56de845327dce03cdd1269b8aa73c6a1ecdb5cfa Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sun, 20 Sep 2026 16:35:23 +0800 Subject: [PATCH 13/97] fix(cli): support non-ASCII Windows agent paths Signed-off-by: lorenzozanee --- src/cli/cli.c | 9 +++------ tests/test_cli.c | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index 51fbafc022..7cd59e0bf3 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -19,6 +19,7 @@ #include "daemon/runtime.h" #include "daemon/version_cohort.h" #include "foundation/compat.h" +#include "foundation/compat_fs.h" #include "foundation/platform.h" #include "foundation/constants.h" #include "foundation/log.h" @@ -2600,12 +2601,8 @@ int cbm_remove_zed_mcp_owned(const char *binary_path, const char *config_path) { /* ── Agent detection ──────────────────────────────────────────── */ 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 + cbm_path_info_t info = {0}; + return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_directory; } /* Resolve the Claude Code config dir. diff --git a/tests/test_cli.c b/tests/test_cli.c index 06ebb38415..b3c3c2f278 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -13074,6 +13074,24 @@ TEST(cli_detect_agents_finds_zed_in_roaming_appdata) { } #endif +#ifdef _WIN32 +TEST(cli_detect_agents_handles_non_ascii_windows_profile) { + char home[1024]; + snprintf(home, sizeof(home), "%s/cbm-用户-profile", cbm_tmpdir()); + test_rmdir_r(home); + char claude_dir[1024]; + snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", home); + if (!test_mkdirp(claude_dir)) + FAIL("could not create non-ASCII Windows profile path"); + + cbm_detected_agents_t agents = cbm_detect_agents(home); + test_rmdir_r(home); + if (!agents.claude_code) + FAIL("Claude detection must support non-ASCII Windows profile paths"); + PASS(); +} +#endif + TEST(cli_detect_agents_finds_antigravity) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX"); @@ -16258,6 +16276,7 @@ SUITE(cli) { #endif #ifdef _WIN32 RUN_TEST(cli_detect_agents_finds_zed_in_roaming_appdata); + RUN_TEST(cli_detect_agents_handles_non_ascii_windows_profile); #endif RUN_TEST(cli_detect_agents_finds_antigravity); RUN_TEST(cli_detect_agents_finds_kilocode); From f4c897110cb1f02edee37db7c8849e63894ed8a4 Mon Sep 17 00:00:00 2001 From: MopicMP Date: Wed, 23 Sep 2026 22:44:16 +0200 Subject: [PATCH 14/97] fix(java): resolve record accessor calls, and never bind a call to data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record's accessors are implicit, so `record Point(double x, double y, double z)` declares no `x()` and extraction emitted no node for one. The call `p.x()` had nothing to resolve to, fell through to the short-name registry, and bound to the first `x` in the project — typically an unrelated class's private field. Measured on two indexed Java trees, that was 278 of 2444 and 2649 of 29509 CALLS edges. Extraction now emits one Method definition per record component, skipping any accessor the record declares itself (JLS 8.10.3), so the accessor has a node and the call resolves to it. And a Java call may no longer bind to a Variable or a Field. A Java call expression never names data, so such an edge is a spelling collision, not a call. The guard follows cbm_go_suppress_bare_field_ref — a pure, label-keyed predicate — and is called from both pass_calls.c and pass_parallel.c so the sequential and parallel resolvers stay identical. Java-gated: Kotlin properties hold function values and C has function pointers, so both legitimately call through data. Signed-off-by: MopicMP Co-authored-by: Claude Opus 5 --- internal/cbm/extract_defs.c | 80 ++++++++++++++++++++++++++ src/pipeline/pass_calls.c | 6 ++ src/pipeline/pass_parallel.c | 6 ++ src/pipeline/pipeline.h | 7 +++ src/pipeline/registry.c | 24 ++++++++ tests/test_call_reference_contract.c | 85 ++++++++++++++++++++++++++++ tests/test_registry.c | 22 +++++++ 7 files changed, 230 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 93da084be1..fd051c482d 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -204,6 +204,7 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch const CBMLangSpec *spec); static TSNode find_class_body(TSNode class_node, CBMLanguage lang); static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *class_qn); +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn); static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); // --- Helpers --- @@ -4642,6 +4643,10 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec extract_enum_members(ctx, node, class_qn); } + if (ctx->language == CBM_LANG_JAVA && strcmp(kind, "record_declaration") == 0) { + extract_record_accessors(ctx, node, class_qn); + } + // Extract methods inside the class extract_class_methods(ctx, node, class_qn, spec); @@ -5529,6 +5534,81 @@ static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *cl } } +/* Whether the record body declares this accessor itself (JLS §8.10.3 lets a + * record override any of them), so the implicit one is not emitted twice. */ +static bool record_declares_accessor(CBMExtractCtx *ctx, TSNode node, const char *comp_name) { + TSNode body = find_class_body(node, ctx->language); + if (ts_node_is_null(body)) { + return false; + } + uint32_t mc = ts_node_named_child_count(body); + for (uint32_t mi = 0; mi < mc; mi++) { + TSNode member = ts_node_named_child(body, mi); + if (strcmp(ts_node_type(member), "method_declaration") != 0) { + continue; + } + TSNode mname = ts_node_child_by_field_name(member, TS_FIELD("name")); + if (ts_node_is_null(mname)) { + continue; + } + char *name = cbm_node_text(ctx->arena, mname, ctx->source); + if (!name || strcmp(name, comp_name) != 0) { + continue; + } + /* Same name, no parameters — that is the accessor. */ + TSNode params = ts_node_child_by_field_name(member, TS_FIELD("parameters")); + if (ts_node_is_null(params) || ts_node_named_child_count(params) == 0) { + return true; + } + } + return false; +} + +/* Java records: the compiler writes one accessor per component, so `p.x()` + * calls a method that appears nowhere in the source. Without a definition for + * it the call has nothing to resolve to and falls through to the project-wide + * name registry, which binds it to the first `x` in the tree — typically an + * unrelated class's private field. Emitting the accessors makes the record's + * own API visible, exactly as the language defines it (JLS §8.10.3). */ +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn) { + CBMArena *a = ctx->arena; + TSNode params = ts_node_child_by_field_name(node, TS_FIELD("parameters")); + if (ts_node_is_null(params)) { + return; + } + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + TSNode comp = ts_node_named_child(params, pi); + if (strcmp(ts_node_type(comp), "formal_parameter") != 0) { + continue; + } + TSNode cname = ts_node_child_by_field_name(comp, TS_FIELD("name")); + if (ts_node_is_null(cname)) { + continue; + } + char *comp_name = cbm_node_text(a, cname, ctx->source); + if (!comp_name || !comp_name[0] || record_declares_accessor(ctx, node, comp_name)) { + continue; + } + CBMDefinition adef; + memset(&adef, 0, sizeof(adef)); + adef.name = comp_name; + adef.qualified_name = cbm_arena_sprintf(a, "%s.%s", class_qn, comp_name); + adef.label = "Method"; + adef.file_path = ctx->rel_path; + adef.start_line = ts_node_start_point(comp).row + TS_LINE_OFFSET; + adef.end_line = ts_node_end_point(comp).row + TS_LINE_OFFSET; + adef.lines = 1; + adef.parent_class = class_qn; + adef.is_exported = true; + TSNode ctype = ts_node_child_by_field_name(comp, TS_FIELD("type")); + if (!ts_node_is_null(ctype)) { + adef.return_type = cbm_node_text(a, ctype, ctx->source); + } + cbm_defs_push(&ctx->result->defs, a, adef); + } +} + /* Resolve the identifier node from a destructure pattern child. * pair_pattern → value field; shorthand/identifier → itself; others → first named child. */ static TSNode destructure_ident(TSNode pat_child) { diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 84958954cf..c702f7efd7 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -682,6 +682,12 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, if (cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { return 0; } + /* A Java call never targets data — see cbm_java_suppress_call_to_data_member. + * The language gate lives here, as with the guards above, and MUST match + * pass_parallel.c exactly or the two resolvers diverge. */ + if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + return 0; + } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, drop_plain_call); return SKIP_ONE; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f1..380679c136 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3028,6 +3028,12 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * CALLS edge across a language boundary. */ continue; } + if (target_node && source_node->id != target_node->id && + cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + /* Same guard as pass_calls.c — a Java call never targets a + * Variable or a Field. */ + 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 diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index e5a672cb0b..416c5b25a9 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -344,6 +344,13 @@ bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target * unit-tested in test_registry.c. */ bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const char *target_label); +/* A Java call expression never names data: `p.x()` is a method invocation + * whatever `x` spells elsewhere in the project. Drops a CALLS bind whose + * target is a Variable or Field. Java only — Kotlin properties and C function + * pointers are callable names that are not methods. Pure; unit-tested in + * test_registry.c. */ +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label); + /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index c99c72d8d5..2a9fb19fbb 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -811,6 +811,30 @@ bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const cha return strcmp(target_label, "Field") == 0; } +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label) { + /* A Java call expression never names data. `p.x()` is a method invocation + * whatever `x` spells elsewhere in the tree, so a CALLS edge into a + * Variable or a Field is a spelling collision and not a call. + * + * The bind is reached when the type-aware resolver finds no method to + * return: a record's accessors are implicit, so `record Point(double x, + * double y, double z)` declares no `x()` for java_lookup_method to find, + * and every `p.x()` falls through to the short-name registry — which + * matches the first `x` anywhere in the project, typically an unrelated + * class's private field. Measured on a 600-file Java tree (2026-09-22): + * 278 of 2444 CALLS edges landed on fields that way, and the callers of + * one four-field class absorbed every record read in the project. + * + * Java-gated, like the Go guard above, because the veto is only sound + * where no callable name can be anything but a method: Kotlin properties + * hold function values (`val f: () -> Unit; f()`) and C has function + * pointers, so both legitimately call through data. */ + if (!is_java || !target_label) { + return false; + } + return strcmp(target_label, "Variable") == 0 || strcmp(target_label, "Field") == 0; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { diff --git a/tests/test_call_reference_contract.c b/tests/test_call_reference_contract.c index 3a7b3acd14..d7aa27939f 100644 --- a/tests/test_call_reference_contract.c +++ b/tests/test_call_reference_contract.c @@ -35,6 +35,40 @@ static int crc_edge_count(cbm_store_t *store, const char *project, const char *t return matches; } +/* Edges of `type` out of `source_name` whose target's qualified name ends with + * `target_qn_suffix`. Two methods in one project can share a short name, and + * then only the qualified name tells them apart. */ +static int crc_edge_count_to_qn(cbm_store_t *store, const char *project, const char *type, + const char *source_name, const char *target_qn_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_type(store, project, type, &edges, &edge_count) != CBM_STORE_OK) { + return -1; + } + int matches = 0; + size_t suffix_len = strlen(target_qn_suffix); + for (int i = 0; i < edge_count; i++) { + cbm_node_t source = {0}; + cbm_node_t target = {0}; + bool source_ok = + cbm_store_find_node_by_id(store, edges[i].source_id, &source) == CBM_STORE_OK; + bool target_ok = + cbm_store_find_node_by_id(store, edges[i].target_id, &target) == CBM_STORE_OK; + if (source_ok && target_ok && source.name && target.qualified_name && + strcmp(source.name, source_name) == 0) { + size_t qn_len = strlen(target.qualified_name); + if (qn_len >= suffix_len && + strcmp(target.qualified_name + qn_len - suffix_len, target_qn_suffix) == 0) { + matches++; + } + } + cbm_node_free_fields(&source); + cbm_node_free_fields(&target); + } + cbm_store_free_edges(edges, edge_count); + return matches; +} + static int crc_global_handler_edge_count(cbm_store_t *store, const char *project, const char *caller) { int references = @@ -423,6 +457,56 @@ TEST(call_reference_go_ambiguous_promoted_method_stays_usage) { PASS(); } +TEST(call_java_record_accessor_binds_the_record) { + /* A record's accessors are implicit, so `record Point(double x, ...)` + * declares no `x()` in its source. Extraction emits them, and `other.x()` + * binds to the record's own accessor. + * + * Two things must not happen instead. The call must not reach the + * project-wide short-name registry and land on the first `x` in the tree — + * here an unrelated class's private field. And the guard that refuses such + * data binds must not swallow a real method that merely shares the + * spelling: the builder's `x(int)`, declared beside its own `x` field, + * keeps its edge. */ + static const RFile files[] = { + {"Point.java", "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double first(Point other) {\n" + " return Maths.abs(other.x()) + new Builder().x(5).made();\n" + " }\n" + "}\n"}, + {"Spring.java", "package com.example;\n" + "public final class Spring {\n" + " private double x;\n" + " public void put(double v) { this.x = v; }\n" + "}\n"}, + {"Builder.java", "package com.example;\n" + "public final class Builder {\n" + " private int x;\n" + " public Builder x(int v) { this.x = v; return this; }\n" + " public int made() { return x; }\n" + "}\n"}, + {"Maths.java", "package com.example;\n" + "public final class Maths {\n" + " public static double abs(double v) { return v < 0 ? -v : v; }\n" + "}\n"}}; + RProj project; + cbm_store_t *store = rh_index_files(&project, files, 4); + ASSERT_NOT_NULL(store); + int fabricated_field = crc_edge_count(store, project.project, "CALLS", "first", "Field", "x"); + int fabricated_var = crc_edge_count(store, project.project, "CALLS", "first", "Variable", "x"); + int genuine = crc_edge_count(store, project.project, "CALLS", "first", "Method", "abs"); + int accessor = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Point.x"); + int builder = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Builder.x"); + rh_cleanup(&project, store); + ASSERT_EQ(fabricated_field, 0); + ASSERT_EQ(fabricated_var, 0); + ASSERT_EQ(genuine, 1); + ASSERT_EQ(accessor, 1); + ASSERT_EQ(builder, 1); + PASS(); +} + SUITE(call_reference_contract) { RUN_TEST(call_reference_typescript_direct_argument_is_exact); RUN_TEST(call_reference_kotlin_alias_argument_is_exact); @@ -439,4 +523,5 @@ SUITE(call_reference_contract) { RUN_TEST(call_reference_python_later_decorated_method_rebinding_stays_usage); RUN_TEST(call_reference_go_bound_method_argument_is_exact); RUN_TEST(call_reference_go_ambiguous_promoted_method_stays_usage); + RUN_TEST(call_java_record_accessor_binds_the_record); } diff --git a/tests/test_registry.c b/tests/test_registry.c index 5f751a8993..126400da61 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -987,6 +987,27 @@ TEST(go_bare_ref_never_binds_field) { PASS(); } +TEST(java_call_never_binds_data_member) { + /* A Java call expression never names data, so a CALLS bind that landed on + * a Variable or a Field is a spelling collision. The shape that produces + * it: a record's accessors are implicit, `p.x()` finds no declared `x()`, + * and the short-name registry hands back the first `x` in the tree — an + * unrelated class's private field. */ + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Field")); + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Variable")); + /* Everything callable is kept, including the constructor's Class target. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Method")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Function")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Class")); + /* Other languages have callable data: a Kotlin property or a C function + * pointer is a name that is not a method and is called all the same. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Field")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Variable")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, NULL)); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -1244,6 +1265,7 @@ SUITE(registry) { RUN_TEST(registry_tie_break_is_independent_of_registration_order); RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); + RUN_TEST(java_call_never_binds_data_member); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); RUN_TEST(python_builtin_member_table_matches_builtin_type_methods); 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 15/97] 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 50c75bdf2e..6ddd8ceaf5 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 905de04a8d..c18f27429b 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 afbdee26f6..46f51feebe 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 16/97] 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 0f376b9609..cdd6b1c7f6 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 46f51feebe..f8b61d53c4 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 17/97] 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 e1de896b8c..e74801b475 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 cdd6b1c7f6..839e482de5 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 f8b61d53c4..887457cdea 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 18/97] 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 e74801b475..c4891c8e06 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 086f501a0c..68e0fbdcc6 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 f6b3913151..57594c665c 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 887457cdea..1adca8bcee 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 19/97] 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 839e482de5..2faf108563 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 68e0fbdcc6..9467d82e6c 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 1adca8bcee..3f65448531 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 e36930742a63efd2bbca491996ba8e37bfa5822c Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 28 Sep 2026 08:59:32 -0500 Subject: [PATCH 20/97] perf(py-lsp): O(1) type membership in the shared Python cross-registry build The shared tier-2 registry registered Python defs one at a time without a mid-build finalize, so every Method's receiver probe scanned all registered types linearly: O(methods x types), single-threaded in lsp_cross_prepare. Keep the per-def registration order and back only the existence probe with a cbm_ht set of type QNs, as the Go and Rust builders do; the registry is identical by construction. On the Python 3.14 stdlib + site-packages the builder drops from 67-75 s to 0.20-0.25 s, with a byte-identical graph. Signed-off-by: Nelson Spence --- internal/cbm/lsp/py_lsp.c | 52 ++++++++++++++++++++--- tests/test_py_lsp.c | 86 +++++++++++++++++++++++++++++++++++++++ tests/test_py_lsp_scale.c | 66 ++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 5 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 8a03961152..961e35f158 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -13,6 +13,7 @@ #include "py_lsp.h" #include "../cbm.h" #include "../helpers.h" +#include "../../../src/foundation/hash_table.h" #include "tree_sitter/api.h" #include #include @@ -4973,10 +4974,36 @@ static const char **py_split_pipe(CBMArena *arena, const char *text) { return out; } +/* Qualified names of reg->types[0..noted), for a caller that registers into a + * registry it does not finalize until the end (cbm_py_build_cross_registry): + * that registry's own lookup scans every type. qns == NULL probes the + * registry instead. */ +typedef struct { + CBMHashTable *qns; + int noted; +} PyKnownTypes; + +/* Note the types added since the last call. Run after every add_type, so an + * add that failed silently cannot desync set and registry. If the set cannot + * grow it is dropped and probes fall back to the registry. */ +static void py_note_known_types(PyKnownTypes *known, const CBMTypeRegistry *reg) { + for (; known && known->qns && known->noted < reg->type_count; known->noted++) { + const char *qn = reg->types[known->noted].qualified_name; + if (!qn || cbm_ht_has(known->qns, qn)) + continue; + cbm_ht_set(known->qns, qn, (void *)(uintptr_t)(known->noted + 1)); + if (!cbm_ht_has(known->qns, qn)) { + cbm_ht_free(known->qns); + known->qns = NULL; + } + } +} + /* Build a registry from CBMLSPDef[] supplied by the caller — covers both - * the source file's own defs and cross-file referenced defs. */ + * the source file's own defs and cross-file referenced defs. known is NULL + * except for the shared Tier-2 build. */ static void py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRegistry *reg, - CBMLSPDef *defs, int def_count) { + CBMLSPDef *defs, int def_count, PyKnownTypes *known) { /* Pass 1: types only — the method pass probes the registry per Method def * (receiver auto-registration), which is a LINEAR scan pre-finalize: * O(methods x types) per file (same quadratic as php_register_lsp_defs; @@ -4999,6 +5026,7 @@ static void py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe rt.method_names = py_split_pipe(arena, d->method_names_str); } cbm_registry_add_type(reg, rt); + py_note_known_types(known, reg); } } /* idx_arena == NULL skips the mid-build finalize: the tier-2 builder @@ -5046,13 +5074,17 @@ static void py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe if (strcmp(d->label, "Method") == 0 && d->receiver_type && d->receiver_type[0]) { rf.receiver_type = d->receiver_type; /* borrowed */ - if (!cbm_registry_lookup_type(reg, rf.receiver_type)) { + bool recv_known = known && known->qns + ? cbm_ht_has(known->qns, rf.receiver_type) + : cbm_registry_lookup_type(reg, rf.receiver_type) != NULL; + if (!recv_known) { CBMRegisteredType auto_t; memset(&auto_t, 0, sizeof(auto_t)); auto_t.qualified_name = rf.receiver_type; const char *dot = strrchr(d->receiver_type, '.'); auto_t.short_name = dot ? dot + 1 : rf.receiver_type; /* borrowed substring */ cbm_registry_add_type(reg, auto_t); + py_note_known_types(known, reg); } } cbm_registry_add_func(reg, rf); @@ -5092,7 +5124,7 @@ void cbm_run_py_lsp_cross(CBMArena *arena, const char *source, int source_len, /* Index allocations go to a per-call scratch arena (see php_lsp_cross). */ CBMArena idx_arena; cbm_arena_init(&idx_arena); - py_register_lsp_defs(arena, &idx_arena, ®, defs, def_count); + py_register_lsp_defs(arena, &idx_arena, ®, defs, def_count, NULL); py_mark_ambiguous_callable_bindings(®); /* Finalize registry — O(1) lookups. See go_lsp.c "3c. Finalize" @@ -5126,14 +5158,24 @@ CBMTypeRegistry *cbm_py_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, i cbm_registry_init(reg, arena); cbm_python_stdlib_register(reg, arena); + /* The registry is not finalized until the end, so probing it for each + * Method's receiver scanned every type: O(methods x types), single-threaded + * (~54 s of lsp_cross_prepare on a large Python tree). Probe a set of + * the registered type QNs instead; registration order is unchanged. Sized + * for the stdlib seed, not def_count (the mixed all-language count). NULL + * on allocation failure: the registry probe runs as before. */ + PyKnownTypes known = {cbm_ht_create((uint32_t)reg->type_count), 0}; + py_note_known_types(&known, reg); + /* Filter to Python defs only — defs[] is mixed-language all_defs. */ for (int i = 0; i < def_count; i++) { CBMLSPDef *d = &defs[i]; if (d->lang != CBM_LANG_PYTHON) continue; /* Reuse the existing register fn on a single-def slice (n=1 inline). */ - py_register_lsp_defs(arena, NULL, reg, d, 1); + py_register_lsp_defs(arena, NULL, reg, d, 1, &known); } + cbm_ht_free(known.qns); py_mark_ambiguous_callable_bindings(reg); cbm_registry_finalize(reg); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 3aaadee519..798c80a7b3 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -637,6 +637,91 @@ TEST(pylsp_fused_self_attr_chain_via_overlay) { PASS(); } +/* Characterization of the shared Tier-2 build order. Defs register one at a + * time in defs[] order, so a Method seen before its Class auto-registers a + * placeholder receiver type that stays ahead of the Class entry, and a Method + * whose receiver is already known adds no type. Any change to how the build + * probes for known receivers must keep this exact sequence. */ +TEST(pylsp_cross_registry_interleaved_order) { + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *base = cbm_py_build_cross_registry(&arena, NULL, 0); + ASSERT_NOT_NULL(base); + int tb = base->type_count, fb = base->func_count; + + CBMLSPDef defs[] = { + {.qualified_name = "t.m.A.m", + .short_name = "m", + .label = "Method", + .receiver_type = "t.m.A", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.A", + .short_name = "A", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.B", + .short_name = "B", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.B.f", + .short_name = "f", + .label = "Method", + .receiver_type = "t.m.B", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.C.g", + .short_name = "g", + .label = "Method", + .receiver_type = "t.m.C", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + /* Receiver auto-registered by C.g just above: no second placeholder. */ + {.qualified_name = "t.m.C.h", + .short_name = "h", + .label = "Method", + .receiver_type = "t.m.C", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + /* Non-Python defs are skipped by the builder. */ + {.qualified_name = "t.m.D", + .short_name = "D", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_GO}, + }; + CBMTypeRegistry *reg = + cbm_py_build_cross_registry(&arena, defs, (int)(sizeof(defs) / sizeof(defs[0]))); + ASSERT_NOT_NULL(reg); + + /* Types: placeholder A (from A.m), Class A, Class B, placeholder C. */ + ASSERT_EQ(reg->type_count, tb + 4); + ASSERT_TRUE(reg->types[tb].qualified_name == defs[0].receiver_type); + ASSERT_STR_EQ(reg->types[tb].short_name, "A"); + ASSERT_TRUE(reg->types[tb + 1].qualified_name == defs[1].qualified_name); + ASSERT_TRUE(reg->types[tb + 2].qualified_name == defs[2].qualified_name); + ASSERT_TRUE(reg->types[tb + 3].qualified_name == defs[4].receiver_type); + ASSERT_STR_EQ(reg->types[tb + 3].short_name, "C"); + + /* Funcs keep defs[] order. */ + ASSERT_EQ(reg->func_count, fb + 4); + ASSERT_STR_EQ(reg->funcs[fb].qualified_name, "t.m.A.m"); + ASSERT_STR_EQ(reg->funcs[fb + 1].qualified_name, "t.m.B.f"); + ASSERT_STR_EQ(reg->funcs[fb + 2].qualified_name, "t.m.C.g"); + ASSERT_STR_EQ(reg->funcs[fb + 2].receiver_type, "t.m.C"); + ASSERT_STR_EQ(reg->funcs[fb + 3].qualified_name, "t.m.C.h"); + + const CBMRegisteredType *a = cbm_registry_lookup_type(reg, "t.m.A"); + ASSERT_NOT_NULL(a); + ASSERT_STR_EQ(a->qualified_name, "t.m.A"); + ASSERT_NULL(cbm_registry_lookup_type(reg, "t.m.D")); + + cbm_arena_destroy(&arena); + PASS(); +} + /* Issue #228: a class/static method invoked directly on a CROSS-FILE imported * class name — ActionRecordX.build_from_text(...) — produced no CALLS edge, so * the method showed in/out degree 0 and was flagged as dead code. Distinct from @@ -2194,6 +2279,7 @@ SUITE(py_lsp) { /* Phase 9 — cross-file + batch */ RUN_TEST(pylsp_crossfile_method_dispatch); RUN_TEST(pylsp_fused_self_attr_chain_via_overlay); + RUN_TEST(pylsp_cross_registry_interleaved_order); RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); RUN_TEST(pylsp_batch_two_files); diff --git a/tests/test_py_lsp_scale.c b/tests/test_py_lsp_scale.c index f20e762753..eee1575fd6 100644 --- a/tests/test_py_lsp_scale.c +++ b/tests/test_py_lsp_scale.c @@ -111,6 +111,72 @@ TEST(pylsp_scale_linear_growth) { PASS(); } +/* Time cbm_py_build_cross_registry over n classes, each followed by two + * methods (extraction order): every method probes the registry for its + * receiver while the registry is still being built. */ +static double measure_cross_registry(int n_classes, int *out_types) { + CBMArena arena; + cbm_arena_init(&arena); + int n_defs = n_classes * 3; + CBMLSPDef *defs = (CBMLSPDef *)cbm_arena_calloc(&arena, (size_t)n_defs * sizeof(*defs)); + if (!defs) { + cbm_arena_destroy(&arena); + return -1.0; + } + for (int i = 0; i < n_classes; i++) { + CBMLSPDef *cls = &defs[i * 3]; + cls->qualified_name = cbm_arena_sprintf(&arena, "scale.mod.Cls%d", i); + cls->short_name = cls->qualified_name + strlen("scale.mod."); + cls->label = "Class"; + cls->def_module_qn = "scale.mod"; + cls->lang = CBM_LANG_PYTHON; + for (int j = 1; j <= 2; j++) { + CBMLSPDef *m = &defs[i * 3 + j]; + m->qualified_name = cbm_arena_sprintf(&arena, "%s.m%d", cls->qualified_name, j); + m->short_name = j == 1 ? "m1" : "m2"; + m->label = "Method"; + m->receiver_type = cls->qualified_name; + m->def_module_qn = "scale.mod"; + m->lang = CBM_LANG_PYTHON; + } + } + struct timespec t0, t1; + clock_gettime(CLOCK_MONOTONIC, &t0); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, n_defs); + clock_gettime(CLOCK_MONOTONIC, &t1); + if (out_types) + *out_types = reg ? reg->type_count : -1; + cbm_arena_destroy(&arena); + return elapsed_ms(t0, t1); +} + +/* The shared build registers defs one at a time into an unfinalized registry; + * a receiver probe that scans every type registered so far makes the build + * O(methods x types). 16x input: linear ~16x time, quadratic well over 100x. */ +TEST(pylsp_scale_cross_registry_build_linear) { + int ty_small = 0, ty_large = 0; + /* Best of three for the small size: it is short enough for host noise. */ + double t_small = measure_cross_registry(1000, &ty_small); + for (int k = 0; k < 2; k++) { + double t = measure_cross_registry(1000, NULL); + if (t >= 0.0 && t < t_small) + t_small = t; + } + double t_large = measure_cross_registry(16000, &ty_large); + printf(" cross registry: 1000 classes=%.1fms (types=%d) 16000 classes=%.1fms " + "(types=%d)\n", + t_small, ty_small, t_large, ty_large); + ASSERT(t_small >= 0.0 && t_large >= 0.0); + ASSERT_EQ(ty_large - ty_small, 15000); + if (t_small > 0.5) { + double ratio = t_large / t_small; + printf(" cross registry ratio 16000/1000: %.1fx (linear ~16x)\n", ratio); + ASSERT(ratio < 48.0); + } + PASS(); +} + SUITE(py_lsp_scale) { RUN_TEST(pylsp_scale_linear_growth); + RUN_TEST(pylsp_scale_cross_registry_build_linear); } 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 21/97] 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 22c62029b4..91b5937ece 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 c877d51102..0d15ac0175 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 ba41b877c8..f2aeb47bed 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 c1d571ecdc..e525136737 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 154518658d..4e7d61dc33 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 7f4c9cc288..50abe5a8c1 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 22af972af2..34563e0c9a 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 56be6b88e3..32f4b1a4c9 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 22/97] 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 91b5937ece..d1f78841be 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 0d15ac0175..cd7b5c7112 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 4c3cde3363..95ed7f63b8 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 f2aeb47bed..6f6655c5f0 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 4e7d61dc33..8c348c3f76 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 7f266eae98..c2a87e0bf3 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 50abe5a8c1..0bec333a8a 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 34563e0c9a..d334c47745 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 32f4b1a4c9..c541db5e2a 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 23/97] 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 cd7b5c7112..31efa9420e 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 6f6655c5f0..be881942e0 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 d334c47745..f42f0dc6a6 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 24/97] 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 b5ddc074df..a77794df64 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 ad728f5061..d4e23e0a4f 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 3f6e7c425b..f2befb408a 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 354cc0026e..d0b8c492d3 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 4ce4748537..45331d89cd 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 9129898e6e38e414f9370bcfc89de91c62571f60 Mon Sep 17 00:00:00 2001 From: Scott Beck <296871+bline@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:46:04 -0600 Subject: [PATCH 25/97] fix(markdown): qualify a repeated heading's Section by its parent headings (#2586) A Section's qualified name is the file's module plus the heading text, so two `## Implementation` headings under `# Service A` and `# Service B` had one qualified name. The graph kept one node (the last), and doclink_md sent the references under both headings to the File node, since neither heading owned the shared node. A heading whose name repeats in the file now takes the names of the headings above it into its qualified name (`docs.repeated.Service-A.Implementation`). tree-sitter-markdown nests each heading's `section` in the section of the heading above it, so the chain is read from the tree. A heading whose name is unique in the file keeps its name alone, so no other Section is renamed. Headings that repeat under the same parents still share a name and keep the existing File fallback (doc_links_md_repeated_headings is unchanged). A heading text may hold dots, so a qualified name can equal another heading's name: `# Service-A.Implementation` against the qualified `## Implementation` under `# Service A`. All of a file's final names are therefore settled together, once per file on its first heading, in the scratch arena. A unique heading keeps its name, or else the first qualified one does, and every other qualified name in the collision takes the first free `~2`, `~3`, .... Names collide only when they come from different headings: the comparison is on the list of heading names, not on the dotted text. Diagrams find their Section through the definition list, so a diagram under a repeated heading now hangs off that heading's own Section. Tests (tests/test_doc_links_md.c): the document from the issue, with a Mermaid diagram under the second heading. Without the fix it fails on the defect: one `Implementation` Section where there are two. The same document plus `# Service-A.Implementation` requires five Sections, the unique heading keeping its name. Without the collision handling it fails with four. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Scott Beck <296871+bline@users.noreply.github.com> --- internal/cbm/cbm.h | 3 + internal/cbm/extract_defs.c | 243 +++++++++++++++++++++++++++++++++++- tests/test_doc_links_md.c | 152 ++++++++++++++++++++++ 3 files changed, 394 insertions(+), 4 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 9fac8cf557..9589e730f3 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -1021,6 +1021,9 @@ typedef struct { * POD section index. */ void *doc_memo; void *doc_pod_index; + /* Markdown: the sorted names of the file's headings (extract_defs.c), + * NULL until the first heading; allocated in `scratch`. */ + void *md_headings; const cbm_test_declarations_t *test_declarations; /* borrowed for this call only */ bool test_declarations_raw_source; struct CBMTestDefinitionMatch *test_definition_matches; /* traversal scratch */ diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index a017a5518a..67af6fc111 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -7030,13 +7030,15 @@ static const char *extract_markdown_section_body(CBMExtractCtx *ctx, TSNode head /* Push a config-language definition that carries prose. push_simple_class_def * delegates here with no docstring. */ -static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *name, - const char *label, const char *docstring) { +/* Push a definition whose qualified name ends with `qn_name` (already QN-safe) + * rather than with its name. */ +static void push_class_def_qn(CBMExtractCtx *ctx, TSNode node, char *name, const char *qn_name, + const char *label, const char *docstring) { CBMArena *a = ctx->arena; CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; - def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, qn_safe_segment(a, name)); + def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, qn_name); def.label = label; def.file_path = ctx->rel_path; def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; @@ -7046,6 +7048,11 @@ static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *nam cbm_defs_push(&ctx->result->defs, a, def); } +static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *name, + const char *label, const char *docstring) { + push_class_def_qn(ctx, node, name, qn_safe_segment(ctx->arena, name), label, docstring); +} + static void push_simple_class_def(CBMExtractCtx *ctx, TSNode node, char *name, const char *label) { push_simple_class_def_doc(ctx, node, name, label, NULL); } @@ -7145,6 +7152,231 @@ static char *extract_markdown_heading_name(CBMArena *a, TSNode node, const char return trim_heading_name(name); } +/* #2586: a heading name that repeats in a file (`## Implementation` under two + * parents) gave its Sections one qualified name, so the graph kept one node + * and the references under them fell back to the File. A repeated heading's + * qualified name therefore takes the names of the headings above it as well + * (`Service-A.Implementation`). A heading whose name is unique in the file + * keeps its name alone, always. A heading text may itself hold dots, so a + * qualified name can equal another heading's final name (`# Service-A. + * Implementation`): the qualified one then takes `~2`, `~3`, ... until no + * other heading of the file has that name. Headings that repeat under the + * same parents too still share a name, as before. All final names are + * settled once per file, on the first heading. */ +typedef struct { + uint32_t start; /* the heading node's first byte */ + TSNode node; /* valid while the file's tree is */ + const char *seg; /* the heading's own QN-safe name */ + const char *key; /* repeated only: the chain's segments, '\n'-joined */ + const char *qn_name; /* the final QN segment */ + bool repeated; +} md_heading_t; + +typedef struct { + md_heading_t *v; /* in document order */ + int count; +} md_headings_t; + +/* The QN-safe name of a heading node, or NULL. */ +static const char *md_heading_segment(CBMExtractCtx *ctx, CBMArena *a, TSNode node) { + const char *kind = ts_node_type(node); + if (!is_markdown_heading_kind(kind)) { + return NULL; + } + char *name = extract_markdown_heading_name(a, node, kind, ctx->source); + return name ? qn_safe_segment(a, name) : NULL; +} + +/* Visit every heading of the file: count them (v NULL), or fill v. */ +static int md_collect_headings(CBMExtractCtx *ctx, CBMArena *a, md_heading_t *v, int cap) { + int n = 0; + if (ts_node_is_null(ctx->root)) { + return 0; + } + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (;;) { + TSNode node = ts_tree_cursor_current_node(&cur); + if (is_markdown_heading_kind(ts_node_type(node))) { + if (!v) { + n++; + } else if (n < cap) { + const char *seg = md_heading_segment(ctx, a, node); + if (seg) { + v[n++] = (md_heading_t){.start = ts_node_start_byte(node), + .node = node, + .seg = seg, + .qn_name = seg}; + } + } + } else if (ts_tree_cursor_goto_first_child(&cur)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cur)) { + if (!ts_tree_cursor_goto_parent(&cur)) { + ts_tree_cursor_delete(&cur); + return n; + } + } + } +} + +static int md_by_seg(const void *x, const void *y) { + return strcmp((*(const md_heading_t *const *)x)->seg, (*(const md_heading_t *const *)y)->seg); +} + +static int md_by_name(const void *x, const void *y) { + const md_heading_t *p = *(const md_heading_t *const *)x; + const md_heading_t *q = *(const md_heading_t *const *)y; + int c = strcmp(p->qn_name, q->qn_name); + return c ? c : (p->start > q->start) - (p->start < q->start); +} + +/* A repeated heading's chain: the names of the headings above it and its own, + * dot-joined (its QN segment) and '\n'-joined (its identity: a QN-safe name + * holds no whitespace). tree-sitter-markdown nests each heading's `section` in + * the section of the heading above it. False on allocation failure. */ +static bool md_heading_chain(CBMExtractCtx *ctx, CBMArena *a, md_heading_t *h) { + const char *dotted = h->seg; + const char *key = h->seg; + TSNode sec = ts_node_parent(h->node); + while (!ts_node_is_null(sec) && strcmp(ts_node_type(sec), "section") == 0) { + TSNode up = ts_node_parent(sec); + if (ts_node_is_null(up) || strcmp(ts_node_type(up), "section") != 0) { + break; + } + const char *above = ts_node_named_child_count(up) > 0 + ? md_heading_segment(ctx, a, ts_node_named_child(up, 0)) + : NULL; + if (above) { + dotted = cbm_arena_sprintf(a, "%s.%s", above, dotted); + key = cbm_arena_sprintf(a, "%s\n%s", above, key); + if (!dotted || !key) { + return false; + } + } + sec = up; + } + h->qn_name = dotted; + h->key = key; + return true; +} + +static bool md_name_taken(const md_headings_t *hs, const char *name) { + for (int i = 0; i < hs->count; i++) { + if (strcmp(hs->v[i].qn_name, name) == 0) { + return true; + } + } + return false; +} + +/* Within a run of headings that share a final name: a unique heading keeps + * it, or else the first chain does; every other chain takes the first free + * `~N`. Headings with one chain (the same headings) move together. */ +static bool md_split_run(CBMArena *a, md_headings_t *hs, md_heading_t **run, int n) { + const char *keep = run[0]->key; /* NULL: a unique heading keeps the name */ + for (int i = 0; i < n; i++) { + if (!run[i]->repeated) { + keep = NULL; + } + } + const char *base = run[0]->qn_name; + for (int i = 0; i < n; i++) { + md_heading_t *h = run[i]; + if (!h->repeated || (keep && strcmp(h->key, keep) == 0) || strcmp(h->qn_name, base) != 0) { + continue; /* the name's keeper, or a chain already moved */ + } + const char *name = NULL; + for (int k = PAIR_LEN; !name || md_name_taken(hs, name); k++) { + name = cbm_arena_sprintf(a, "%s~%d", base, k); + if (!name) { + return false; + } + } + for (int j = i; j < n; j++) { + if (run[j]->repeated && strcmp(run[j]->key, h->key) == 0) { + run[j]->qn_name = name; + } + } + } + return true; +} + +static bool md_settle_names(CBMExtractCtx *ctx, CBMArena *a, md_headings_t *hs) { + int n = hs->count; + md_heading_t **by = (md_heading_t **)cbm_arena_alloc(a, (size_t)n * sizeof(*by)); + if (!by) { + return false; + } + for (int i = 0; i < n; i++) { + by[i] = &hs->v[i]; + hs->v[i].qn_name = hs->v[i].seg; + } + qsort(by, (size_t)n, sizeof(*by), md_by_seg); + for (int i = 1; i < n; i++) { + if (strcmp(by[i]->seg, by[i - SKIP_ONE]->seg) == 0) { + by[i]->repeated = by[i - SKIP_ONE]->repeated = true; + } + } + for (int i = 0; i < n; i++) { + if (hs->v[i].repeated && !md_heading_chain(ctx, a, &hs->v[i])) { + return false; + } + } + qsort(by, (size_t)n, sizeof(*by), md_by_name); + for (int i = 0; i < n;) { + int j = i + SKIP_ONE; + while (j < n && strcmp(by[j]->qn_name, by[i]->qn_name) == 0) { + j++; + } + if (j - i > SKIP_ONE && !md_split_run(a, hs, by + i, j - i)) { + return false; + } + i = j; + } + return true; +} + +static const md_headings_t *md_headings(CBMExtractCtx *ctx) { + if (ctx->md_headings) { + return (const md_headings_t *)ctx->md_headings; + } + CBMArena *a = doc_scratch(ctx); + md_headings_t *hs = (md_headings_t *)cbm_arena_alloc(a, sizeof(*hs)); + if (!hs) { + return NULL; + } + int cap = md_collect_headings(ctx, a, NULL, 0); + hs->v = cap > 0 ? (md_heading_t *)cbm_arena_alloc(a, (size_t)cap * sizeof(md_heading_t)) : NULL; + hs->count = hs->v ? md_collect_headings(ctx, a, hs->v, cap) : 0; + if (hs->count > SKIP_ONE && !md_settle_names(ctx, a, hs)) { + hs->count = 0; /* every heading keeps its own name, as before */ + } + ctx->md_headings = hs; + return hs; +} + +/* The QN segment of a heading: its own name unless the file's names say + * otherwise. */ +static const char *md_heading_qn_name(CBMExtractCtx *ctx, TSNode heading, const char *seg) { + const md_headings_t *hs = md_headings(ctx); + uint32_t start = ts_node_start_byte(heading); + int lo = 0; + int hi = hs ? hs->count : 0; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (hs->v[mid].start < start) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + if (hs && lo < hs->count && hs->v[lo].start == start && strcmp(hs->v[lo].seg, seg) == 0) { + return hs->v[lo].qn_name; + } + return seg; +} + // INI: extract section name from section node. static char *find_ini_section_name(CBMArena *a, TSNode node, const char *source) { uint32_t nc = ts_node_child_count(node); @@ -7245,7 +7477,10 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char return false; } - if (name && name[0]) { + if (name && name[0] && ctx->language == CBM_LANG_MARKDOWN) { + const char *seg = qn_safe_segment(a, name); + push_class_def_qn(ctx, node, name, md_heading_qn_name(ctx, node, seg), label, docstring); + } else if (name && name[0]) { push_simple_class_def_doc(ctx, node, name, label, docstring); } return true; diff --git a/tests/test_doc_links_md.c b/tests/test_doc_links_md.c index 21e74f37e9..843b486819 100644 --- a/tests/test_doc_links_md.c +++ b/tests/test_doc_links_md.c @@ -193,6 +193,156 @@ TEST(doc_links_md_repeated_headings) { PASS(); } +/* MENTIONS edges from the Section whose qualified name ends with `src` to the + * node whose qualified name ends with `tgt`. */ +static int rep_edge(const char *db, const char *src, const char *tgt) { + char props[512]; + int n = 0; + dm_edge(db, src, tgt, props, sizeof(props), &n); + return n; +} + +/* #2586: two `## Implementation` headings under different parents had one + * qualified name, so the graph kept one Section (the last) and the references + * under both went to the File. A repeated heading is now qualified by the + * headings above it; a unique heading keeps its name alone. */ +TEST(doc_links_md_repeated_headings_by_parent_issue2586) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_rep_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::balance`.\n" + "\n" + "# Service B\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::post_entry`.\n" + "\n" + "```mermaid\n" + "graph LR\n" + " Ledger --> Balance\n" + "```\n" + "\n" + "## Notes\n" + "\n" + "See `src/ledger.py`.\n"); + th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n" + " ledger.append(amount)\n" + " return balance(ledger)\n" + "\n" + "\n" + "def balance(ledger):\n" + " return sum(ledger)\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/md.db", tmp); + ASSERT_EQ(dm_index(tmp, db, NULL), 0); + + /* two Sections, each qualified by its parent heading */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND name = 'Implementation' AND file_path = 'docs/repeated.md'"), + 2); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation' " + "AND start_line = 3"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-B.Implementation' " + "AND start_line = 9"), + 1); + /* unique headings keep their names */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' AND " + "(qualified_name LIKE '%.repeated.Service-A' OR " + "qualified_name LIKE '%.repeated.Notes')"), + 2); + + /* each reference from its own Section, none from the File */ + ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.balance"), 1); + ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1); + ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.post_entry"), 0); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.label = 'File' " + "AND s.file_path = 'docs/repeated.md'"), + 0); + + /* the diagram under the second heading is that Section's */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = 'DEFINES' " + "AND t.label = 'Diagram' " + "AND s.qualified_name LIKE '%.Service-B.Implementation'"), + 1); + + th_cleanup(tmp); + PASS(); +} + +/* #2586 review: a heading text may hold dots, so `# Service-A.Implementation` + * named the same as the qualified `## Implementation` under `# Service A`. + * The unique heading keeps its name; the qualified one takes `~2`. Five + * headings, five Sections, every reference from its own. */ +TEST(doc_links_md_repeated_heading_name_collision_issue2586) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_col_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::balance`.\n" + "\n" + "# Service B\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::post_entry`.\n" + "\n" + "# Service-A.Implementation\n" + "\n" + "See `src/ledger.py`.\n"); + th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n" + " ledger.append(amount)\n" + " return balance(ledger)\n" + "\n" + "\n" + "def balance(ledger):\n" + " return sum(ledger)\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/md.db", tmp); + ASSERT_EQ(dm_index(tmp, db, NULL), 0); + + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND file_path = 'docs/repeated.md'"), + 5); + /* the unique heading keeps its name; the qualified one moves aside */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation' " + "AND start_line = 13"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation~2' " + "AND start_line = 3"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-B.Implementation' " + "AND start_line = 9"), + 1); + + /* each reference from its own Section, none from the File */ + ASSERT_EQ(rep_edge(db, "Service-A.Implementation~2", "ledger.balance"), 1); + ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1); + ASSERT_EQ(rep_edge(db, "repeated.Service-A.Implementation", "ledger.py.__file__"), 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.label = 'File' " + "AND s.file_path = 'docs/repeated.md'"), + 0); + + th_cleanup(tmp); + PASS(); +} + typedef struct { const char *text; CBMDocLinkMdShape shape; @@ -863,6 +1013,8 @@ SUITE(doc_links_md) { dm_ship_held_families(); RUN_TEST(doc_links_md_extract_tokens); RUN_TEST(doc_links_md_repeated_headings); + RUN_TEST(doc_links_md_repeated_headings_by_parent_issue2586); + RUN_TEST(doc_links_md_repeated_heading_name_collision_issue2586); RUN_TEST(doc_links_md_classify_span); RUN_TEST(doc_links_md_resolve); RUN_TEST(doc_links_md_incremental); From 6bb7acdf3af41e6fc317f7f581c80170ffe67b5a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 00:42:36 +0200 Subject: [PATCH 26/97] 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 d4e23e0a4f..b89a20b941 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 27/97] 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 9abd26f9a4..2aae26080b 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 96936d0dd2..d13002cd97 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 28/97] 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 b89a20b941..38bea7b4de 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 29/97] 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 5d5aa17163..a8ced098c3 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 c4b635b25f7a51670642e5100c6e73d6406be4a1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:24:56 +0200 Subject: [PATCH 30/97] fix(store,ui): route the paired-free allocations through the memory core The three exported release functions added for #1762 freed raw heap blocks. Since the memory-core ratchet landed, the allocating side has to go through the core as well, and on current main cbm_leiden already allocates its result in CBM_MEM_CLASS_OTHER, so a raw free() in cbm_leiden_free left the class counters drifting. - cbm_store_fetch_call_edges: allocate and grow the source/target arrays with cbm_alloc/cbm_realloc in CBM_MEM_CLASS_STORE. An allocation failure now finalizes the statement, releases both arrays and returns CBM_STORE_ERR instead of writing through a NULL array. - cbm_store_free_call_edges, cbm_leiden_free and cbm_layout_free_json release with cbm_free in the class the matching allocation used. - cbm_layout_to_json writes its output through cbm_json_writer_allocator, so the string is an OTHER-class core block; the UI server's five release sites now call cbm_layout_free_json. - The header comments name the paired free instead of "caller frees". - Tests: ownership checks for all three pairs measure the class counters across the allocating call and the paired free, so a raw allocation on either side fails; the call-edge check grows past the initial capacity. Refs #1762 Signed-off-by: Martin Vogel --- src/store/store.c | 39 ++++++++++++++++++++------ src/store/store.h | 5 ++-- src/ui/http_server.c | 10 +++---- src/ui/layout3d.c | 9 ++++-- src/ui/layout3d.h | 2 +- tests/test_store_arch.c | 62 ++++++++++++++++++++++++++++++++++++++++- tests/test_ui.c | 35 +++++++++++++++++++++++ 7 files changed, 141 insertions(+), 21 deletions(-) diff --git a/src/store/store.c b/src/store/store.c index 92b78a2cad..069e06b1d0 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -4123,10 +4123,18 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge bind_text(stmt, SKIP_ONE, project); sqlite3_bind_int(stmt, 2, max_edges > 0 ? max_edges + SKIP_ONE : CBM_NOT_FOUND); + /* Both arrays are STORE-class memory-core blocks: the caller releases them + * with cbm_store_free_call_edges, the only free that matches. */ int cap = ST_INIT_CAP_16; int n = 0; - int64_t *src = malloc((size_t)cap * sizeof(int64_t)); - int64_t *tgt = malloc((size_t)cap * sizeof(int64_t)); + int64_t *src = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); + int64_t *tgt = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); + if (!src || !tgt) { + sqlite3_finalize(stmt); + cbm_store_free_call_edges(src, tgt); + store_set_error(s, "fetch_call_edges: out of memory"); + return CBM_STORE_ERR; + } int scan_rc; while ((scan_rc = sqlite3_step(stmt)) == SQLITE_ROW) { if (max_edges > 0 && n >= max_edges) { @@ -4136,9 +4144,23 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge break; } if (n >= cap) { + size_t grown_bytes = (size_t)cap * ST_GROWTH * sizeof(int64_t); + int64_t *grown_src = cbm_realloc(CBM_MEM_CLASS_STORE, src, grown_bytes); + if (grown_src) { + src = grown_src; + } + int64_t *grown_tgt = + grown_src ? cbm_realloc(CBM_MEM_CLASS_STORE, tgt, grown_bytes) : NULL; + if (grown_tgt) { + tgt = grown_tgt; + } + if (!grown_src || !grown_tgt) { + sqlite3_finalize(stmt); + cbm_store_free_call_edges(src, tgt); + store_set_error(s, "fetch_call_edges: out of memory"); + return CBM_STORE_ERR; + } cap *= ST_GROWTH; - src = safe_realloc(src, (size_t)cap * sizeof(int64_t)); - tgt = safe_realloc(tgt, (size_t)cap * sizeof(int64_t)); } src[n] = sqlite3_column_int64(stmt, 0); tgt[n] = sqlite3_column_int64(stmt, 1); @@ -4146,8 +4168,7 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge } sqlite3_finalize(stmt); if (scan_rc != SQLITE_DONE && scan_rc != SQLITE_ROW) { - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); store_set_error_sqlite(s, "fetch_call_edges scan"); return CBM_STORE_ERR; } @@ -4158,8 +4179,8 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge } void cbm_store_free_call_edges(int64_t *src, int64_t *tgt) { - free(src); - free(tgt); + cbm_free(CBM_MEM_CLASS_STORE, src); + cbm_free(CBM_MEM_CLASS_STORE, tgt); } int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, @@ -10499,7 +10520,7 @@ int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t * } void cbm_leiden_free(cbm_louvain_result_t *result) { - free(result); + cbm_free(CBM_MEM_CLASS_OTHER, result); /* cbm_leiden allocates OTHER */ } /* ── Architecture: community clusters via Leiden ───────────────── */ diff --git a/src/store/store.h b/src/store/store.h index 3e2f3a5468..59c281443b 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -708,7 +708,8 @@ int64_t cbm_store_insert_edge(cbm_store_t *s, const cbm_edge_t *e); int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int count); /* Fetch all CALLS edges among Function/Method nodes for a project as parallel - * (source_id, target_id) arrays (caller frees both). For SCC / cycle analysis. + * (source_id, target_id) arrays, released together with + * cbm_store_free_call_edges. For SCC / cycle analysis. * Stops at max_edges and sets *truncated — never a silent cap. Returns * CBM_STORE_OK (or _ERR); *count is the number returned. */ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges, @@ -1291,7 +1292,7 @@ typedef struct { * reported community is internally connected. The resolution parameter * controls granularity (higher -> more, smaller communities); 1.0 is standard. * Allocates *out (length *out_count == node_count) in CBM_MEM_CLASS_OTHER; - * the caller releases it with cbm_free(CBM_MEM_CLASS_OTHER, *out). */ + * the caller releases it with cbm_leiden_free(*out). */ int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, int edge_count, double resolution, cbm_louvain_result_t **out, int *out_count); diff --git a/src/ui/http_server.c b/src/ui/http_server.c index ffc834f6d4..6e153686ed 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -2042,7 +2042,7 @@ static bool attach_missed_graph(yyjson_mut_doc *mdoc, yyjson_mut_val *mroot, cbm return false; } yyjson_doc *mldoc = yyjson_read(mjson, strlen(mjson), 0); - free(mjson); + cbm_layout_free_json(mjson); if (!mldoc) { return false; } @@ -2164,7 +2164,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } cbm_http_replyf(c, 200, g_cors_json, "%s", scoped_json); - free(scoped_json); + cbm_layout_free_json(scoped_json); return; } @@ -2200,13 +2200,13 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { if (linked_count == 0 && missed_graph) { cbm_store_close(store); cbm_http_replyf(c, 200, g_cors_json, "%s", primary_json); - free(primary_json); + cbm_layout_free_json(primary_json); return; } /* Parse primary JSON and append missed_graph + linked_projects */ yyjson_doc *pdoc = yyjson_read(primary_json, strlen(primary_json), 0); - free(primary_json); + cbm_layout_free_json(primary_json); if (!pdoc) { cbm_store_close(store); cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON parse failed\"}"); @@ -2258,7 +2258,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Parse linked project layout */ yyjson_doc *lpdoc = yyjson_read(lp_json, strlen(lp_json), 0); - free(lp_json); + cbm_layout_free_json(lp_json); if (!lpdoc) { cbm_store_close(lp_store); free(linked[li]); diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 89497837a8..c9abaf773d 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -14,6 +14,7 @@ #include "ui/layout3d.h" #include "ui/layout_internal.h" #include "foundation/log.h" +#include "foundation/json_writer.h" #include "foundation/mem_core.h" #include "foundation/platform.h" @@ -878,10 +879,12 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { yyjson_mut_obj_add_val(doc, root, "edges", ea); yyjson_mut_obj_add_int(doc, root, "total_nodes", r->total_nodes); + /* The output string is an OTHER-class memory-core block, released by + * cbm_layout_free_json; the document keeps its own allocator. */ size_t len = 0; yyjson_write_err write_err = {0}; - char *json = - yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL, &len, &write_err); + char *json = yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, + cbm_json_writer_allocator(), &len, &write_err); yyjson_mut_doc_free(doc); if (!json) { char code[CBM_SZ_32]; @@ -893,5 +896,5 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { } void cbm_layout_free_json(char *json) { - free(json); + cbm_free(CBM_MEM_CLASS_OTHER, json); } diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h index 29b13712a4..6d4cd22edf 100644 --- a/src/ui/layout3d.h +++ b/src/ui/layout3d.h @@ -69,7 +69,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, /* Release a computed layout result and its fields through their matching allocators. */ void cbm_layout_free(cbm_layout_result_t *result); -/* Serialize layout result to JSON string. Caller must free(). */ +/* Serialize layout result to JSON string. Release with cbm_layout_free_json(). */ char *cbm_layout_to_json(const cbm_layout_result_t *result); /* Free the JSON string returned by cbm_layout_to_json. */ diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 50d4345634..92af0994e6 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -1431,6 +1431,64 @@ TEST(fetch_call_edges_empty_project) { PASS(); } +/* The two arrays cbm_store_fetch_call_edges hands out are memory-core blocks of + * the STORE class, and cbm_store_free_call_edges is what takes them back. Both + * halves are measured: the fetch adds exactly the two arrays and the paired free + * removes exactly those, so a raw allocation on either side fails. Thirty-six + * edges outgrow the initial capacity of 16, so the growth path is covered too. */ +TEST(fetch_call_edges_memory_core_ownership) { + enum { FN = 37 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "grow", "/tmp/grow"); + int64_t ids[FN]; + for (int i = 0; i < FN; i++) { + char name[32]; + char qn[64]; + snprintf(name, sizeof(name), "f%d", i); + snprintf(qn, sizeof(qn), "grow.pkg.f%d", i); + cbm_node_t n = {.project = "grow", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "pkg/f.go"}; + ids[i] = cbm_store_upsert_node(s, &n); + ASSERT_TRUE(ids[i] > 0); + } + for (int i = 0; i + 1 < FN; i++) { + cbm_edge_t e = { + .project = "grow", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"}; + ASSERT_TRUE(cbm_store_insert_edge(s, &e) > 0); + } + + size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE); + size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE); + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "grow", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, FN - 1); + ASSERT_FALSE(truncated); + ASSERT_NOT_NULL(src); + ASSERT_NOT_NULL(tgt); + /* Ordered by (source_id, target_id): growth kept every pair in place. */ + ASSERT_EQ(src[0], ids[0]); + ASSERT_EQ(tgt[0], ids[1]); + ASSERT_EQ(src[FN - 2], ids[FN - 2]); + ASSERT_EQ(tgt[FN - 2], ids[FN - 1]); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks + 2); + ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE) >= + bytes + 2 * (size_t)count * sizeof(int64_t)); + + cbm_store_free_call_edges(src, tgt); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE), bytes); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks); + cbm_store_close(s); + PASS(); +} + /* ── Louvain tests ──────────────────────────────────────────────── */ TEST(louvain_basic) { @@ -1564,7 +1622,8 @@ TEST(leiden_result_memory_core_ownership) { ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1); ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER) >= bytes + 3 * sizeof(*result)); - cbm_free(CBM_MEM_CLASS_OTHER, result); + /* Released through the exported pair, the way an FFI caller must. */ + cbm_leiden_free(result); ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes); ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks); } @@ -2014,6 +2073,7 @@ SUITE(store_arch) { RUN_TEST(fetch_call_edges_basic); RUN_TEST(fetch_call_edges_truncated); RUN_TEST(fetch_call_edges_empty_project); + RUN_TEST(fetch_call_edges_memory_core_ownership); /* Louvain */ RUN_TEST(louvain_basic); diff --git a/tests/test_ui.c b/tests/test_ui.c index 4d31410d91..8c3a600060 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -562,6 +562,40 @@ TEST(layout_to_json) { PASS(); } +/* cbm_layout_to_json hands out one memory-core block of the OTHER class, and + * cbm_layout_free_json takes exactly that block back. A raw allocation on either + * side of the pair fails the count. */ +TEST(layout_json_memory_core_ownership) { + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "test", "/tmp/test"); + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "hello", + .qualified_name = "test::hello", + .file_path = "a.c", + .start_line = 1, + .end_line = 5}; + cbm_store_upsert_node(store, &n); + cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + ASSERT_NOT_NULL(r); + + size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER); + size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER); + char *json = cbm_layout_to_json(r); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"hello\"") != NULL); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1); + ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER) >= bytes + strlen(json) + 1); + + cbm_layout_free_json(json); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks); + cbm_layout_free(r); + cbm_store_close(store); + PASS(); +} + TEST(layout_null_inputs) { /* NULL store → NULL result */ cbm_layout_result_t *r = cbm_layout_compute(NULL, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); @@ -2698,6 +2732,7 @@ SUITE(ui) { RUN_TEST(layout_honors_budget_above_default); RUN_TEST(layout_deterministic); RUN_TEST(layout_to_json); + RUN_TEST(layout_json_memory_core_ownership); RUN_TEST(layout_null_inputs); RUN_TEST(layout_dead_code_classification); RUN_TEST(layout_coincident_nodes_bounded); From a909f8418e9fb0fe52372d82f92c41a53e2f6fe7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:27:00 +0200 Subject: [PATCH 31/97] 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 92e5cb873c..5c17aa6395 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 811cd31e04..4a86968242 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 864484d3d80255d2fed801e64ee791c872a9adba Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:28:10 +0200 Subject: [PATCH 32/97] fix(cli): move index verdict exit codes off the usage-error code The binary already exits 2 for a usage error (an invalid --tool-profile), and shells and CI tooling read 2 that way. With the quality verdict on 2 a gate could not tell "the index is below threshold" from "the command line was wrong". The two verdicts move to codes nothing else uses: 0 indexed, at or above the thresholds (unchanged) 1 hard failure in a reachable repository (unchanged) 10 indexed, below a quality threshold (was 2) 11 target unavailable (was 3) The header now states that these values are a stable contract, and a new test pins the four numbers so a renumbering fails in the suite rather than in a caller's pipeline. Requested in the maintainer review of #2092. Co-authored-by: Vladislav Rahmanov Signed-off-by: Martin Vogel --- src/cli/cli.h | 22 ++++++++++++++-------- tests/test_cli.c | 17 +++++++++++++++-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/cli/cli.h b/src/cli/cli.h index cc96c591e8..bdd0c156c5 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -87,14 +87,20 @@ int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_canc * A freshness gate must not have to read anyone's account of itself: it reads * the process status. `index_repository` therefore grades its own outcome. * - * 0 indexed, quality at or above the thresholds - * 1 hard failure (pipeline fell over inside a reachable repository) - * 2 indexed, but below a quality threshold — a graph that exists and lies - * about how much of the tree it covers is worse than no graph - * 3 target unavailable: repo_path is absent, unreadable, or not a directory + * 0 indexed, quality at or above the thresholds + * 1 hard failure (pipeline fell over inside a reachable repository) + * 10 indexed, but below a quality threshold — a graph that exists and lies + * about how much of the tree it covers is worse than no graph + * 11 target unavailable: repo_path is absent, unreadable, or not a directory * * 1 keeps its historical meaning so existing `|| fail` scripts are unaffected; - * 2 and 3 are new and carve out cases that used to return 0 and 1. + * 10 and 11 are new and carve out cases that used to return 0 and 1. + * + * STABLE CONTRACT: these four values are public. CI pipelines branch on them, + * so they are never renumbered or reused for another outcome. 2 is + * deliberately not a verdict: the binary already returns 2 for a usage error + * (an invalid --tool-profile), and shells and CI tooling read 2 that way, so a + * verdict on 2 could not be told apart from a mistyped command line. * * The contract is opt-in: it applies only when CBM_GATE is set to a non-empty * value other than "0". Without it the CLI exits exactly as before, so a @@ -114,8 +120,8 @@ int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_canc enum { CBM_CLI_EXIT_OK = 0, CBM_CLI_EXIT_FAILURE = 1, - CBM_CLI_EXIT_QUALITY = 2, - CBM_CLI_EXIT_TARGET = 3, + CBM_CLI_EXIT_QUALITY = 10, + CBM_CLI_EXIT_TARGET = 11, }; /* Grade an index_repository result envelope. `base_status` is what the diff --git a/tests/test_cli.c b/tests/test_cli.c index 3f2d506c7d..54dc5ed29b 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -18263,8 +18263,20 @@ static void cli_gate_env_restore(cli_gate_env_t saved) { restore_test_env("CBM_GATE_MAX_PARTIAL_PCT", saved.partial_pct); } +/* The verdict codes are a public contract: CI pipelines branch on the + * numbers, so a renumbering must fail here rather than in someone's + * pipeline. Neither verdict may sit on 2, which the binary already returns + * for a usage error and which shells and CI tooling read that way. */ +TEST(cli_index_exit_codes_are_a_stable_contract) { + ASSERT_EQ(CBM_CLI_EXIT_OK, 0); + ASSERT_EQ(CBM_CLI_EXIT_FAILURE, 1); + ASSERT_EQ(CBM_CLI_EXIT_QUALITY, 10); + ASSERT_EQ(CBM_CLI_EXIT_TARGET, 11); + PASS(); +} + /* Without CBM_GATE the CLI exits exactly as it always did: a payload that - * would grade 2 or 3 with the gate on keeps the base status. "0" and the + * would grade 10 or 11 with the gate on keeps the base status. "0" and the * empty string count as off, so an exported CBM_GATE=0 is not a surprise. */ TEST(cli_index_exit_gate_off_keeps_base_status) { cli_gate_env_t saved = cli_gate_env_clear(); @@ -18387,7 +18399,7 @@ TEST(cli_index_exit_separates_missing_target_from_broken_run) { } /* Silence is never upgraded into a verdict. A payload the grader cannot - * read says nothing about index quality, and inventing a 2 from it would + * read says nothing about index quality, and inventing a 10 from it would * fail runs for the crime of an unexpected response shape. */ TEST(cli_index_exit_never_upgrades_silence) { cli_gate_env_t saved = cli_gate_env_clear(); @@ -18989,6 +19001,7 @@ SUITE(cli) { RUN_TEST(cli_stdin_args_gate_tracks_tool_schema_issue1359); /* index_repository exit contract */ + RUN_TEST(cli_index_exit_codes_are_a_stable_contract); RUN_TEST(cli_index_exit_gate_off_keeps_base_status); RUN_TEST(cli_index_exit_clean_run_stays_zero); RUN_TEST(cli_index_exit_unusable_file_is_a_quality_failure); From cb62f63739e934706a66422c7e14a4901af9bc1e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:28:10 +0200 Subject: [PATCH 33/97] fix(cli): read gate thresholds through cbm_env_long cli_gate_threshold re-implemented the project's strict whole-number environment reader and missed one case: strtol skips leading blanks, so CBM_GATE_MAX_PARTIAL_PCT=" 20" was read as 20, although the test lists a leading blank as unreadable. cbm_env_long (src/foundation/platform.h) refuses empty text, leading blanks, trailing junk and out-of-range values, leaves its output untouched on refusal, and reads the Windows environment the same way every other setting does. Anything unreadable now falls back to the documented default. The test's numeric-looking unreadable values move from 10 to 50. At 10 they equalled the default, so a reader that wrongly accepted one still failed the 20% payload and the test could not tell the difference. At 50 an accepted value passes the run and the test fails; against the previous reader it does, on " 50". Requested in the maintainer review of #2092. Co-authored-by: Vladislav Rahmanov Signed-off-by: Martin Vogel --- src/cli/cli.c | 21 +++++++-------------- tests/test_cli.c | 10 +++++++--- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index cbbdec0684..9a1dd25b93 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -160,21 +160,14 @@ int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_canc } /* One override, parsed strictly: a typo must not silently disable a gate, so - * anything that is not a whole number falls back to the documented default. */ + * anything that is not a whole number falls back to the documented default. + * cbm_env_long is the project's strict reader for exactly this case: it + * refuses empty text, blanks, trailing junk and values too large for a long + * (strtol would clamp those to LONG_MAX, a ceiling no run can exceed), and it + * reads the Windows environment the same way every other setting does. */ static long cli_gate_threshold(const char *name, long fallback) { - const char *raw = getenv(name); - if (!raw || !raw[0]) { - return fallback; - } - char *end = NULL; - errno = 0; - long value = strtol(raw, &end, 10); - /* Overflow clamps to LONG_MAX with the whole string consumed, which would - * read as a ceiling no run can exceed: the one typo that disables a gate. */ - if (errno == ERANGE || end == raw || *end != '\0') { - return fallback; - } - return value; + long value = 0; + return cbm_env_long(name, &value) ? value : fallback; } /* The contract is opt-in. Unset, empty, or "0" leaves the historical 0/1 diff --git a/tests/test_cli.c b/tests/test_cli.c index 54dc5ed29b..ec898e99d1 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -18482,10 +18482,14 @@ TEST(cli_index_exit_unreadable_threshold_falls_back_to_default) { cli_gate_env_t saved = cli_gate_env_clear(); const char *twenty_percent = "{\"status\":\"ok\",\"files_indexed\":100," "\"parse_partial_count\":20,\"parse_unusable_count\":0}"; - /* The last one overflows long: strtol clamps it to LONG_MAX with the - * whole string consumed, a ceiling no run could exceed. */ + /* The numeric-looking values carry 50, not the default 10: a reader that + * wrongly accepted one would raise the ceiling above the 20% payload and + * pass the run, so every refusal is visible. " 10" could not show that, + * and plain strtol does accept a leading blank. The last one overflows + * long: strtol clamps it to LONG_MAX with the whole string consumed, a + * ceiling no run could exceed. */ const char *unreadable[] = { - "abc", "1O", "10pct", " 10", "10 ", "", "1e1", "10.0", "99999999999999999999999"}; + "abc", "1O", "50pct", " 50", "50 ", "", "5e1", "50.0", "99999999999999999999999"}; for (size_t i = 0; i < sizeof(unreadable) / sizeof(unreadable[0]); i++) { cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", unreadable[i], 1); int status = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); From f08dadae9c6b439e167c8cebbf429e6aad1b5bff Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:29:02 +0200 Subject: [PATCH 34/97] test(py-lsp): gate the shared cross-registry build on counted work Replace the wall-clock ratio test pylsp_scale_cross_registry_build_linear (py_lsp_scale suite) with pylsp_cross_registry_build_work_is_linear in the py_lsp suite. The new test builds the same generated input (n classes, each followed by two methods, in extraction order) and reads the seam-build CBM_LSP_WORK counter instead of a clock, so its verdict does not depend on host load, and it runs in PR CI, which skips the perf suites. 4x the classes must cost at most ~4x the counted work. With the type-QN set the build examines no registry candidates (0 at 1000 and 4000 classes); with the scanning receiver probe the work grows from 2,809,000 to 23,236,000 and the test fails. Signed-off-by: Martin Vogel --- tests/test_py_lsp.c | 54 ++++++++++++++++++++++++++++++++ tests/test_py_lsp_scale.c | 66 --------------------------------------- 2 files changed, 54 insertions(+), 66 deletions(-) diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 85b17c9d64..b64b1dce26 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -2483,6 +2483,59 @@ TEST(pylsp_scale_work_is_linear) { PASS(); } +/* #2413 (O9: no clock): the shared Tier-2 build registers one def at a time + * into a registry it finalizes only at the end, so a receiver probe that scans + * the types registered so far makes it O(methods x types). The registry + * primitives count every candidate they examine (CBM_LSP_WORK): 4x the classes + * must cost at most ~4x the work; the scanning probe lands at ~16x. */ +static uint64_t cross_registry_work_for(int n_classes, int *out_types) { + CBMArena arena; + cbm_arena_init(&arena); + int n_defs = n_classes * 3; + CBMLSPDef *defs = (CBMLSPDef *)cbm_arena_calloc(&arena, (size_t)n_defs * sizeof(*defs)); + if (!defs) { + cbm_arena_destroy(&arena); + *out_types = -1; + return 0; + } + /* Extraction order: each class followed by its two methods. */ + for (int i = 0; i < n_classes; i++) { + CBMLSPDef *cls = &defs[i * 3]; + cls->qualified_name = cbm_arena_sprintf(&arena, "scale.mod.Cls%d", i); + cls->short_name = cls->qualified_name + strlen("scale.mod."); + cls->label = "Class"; + cls->def_module_qn = "scale.mod"; + cls->lang = CBM_LANG_PYTHON; + for (int j = 1; j <= 2; j++) { + CBMLSPDef *m = &defs[i * 3 + j]; + m->qualified_name = cbm_arena_sprintf(&arena, "%s.m%d", cls->qualified_name, j); + m->short_name = j == 1 ? "m1" : "m2"; + m->label = "Method"; + m->receiver_type = cls->qualified_name; + m->def_module_qn = "scale.mod"; + m->lang = CBM_LANG_PYTHON; + } + } + (void)cbm_lsp_work_take(); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, n_defs); + uint64_t work = cbm_lsp_work_take(); + *out_types = reg ? reg->type_count : -1; + cbm_arena_destroy(&arena); + return work; +} + +TEST(pylsp_cross_registry_build_work_is_linear) { + int t1 = 0, t4 = 0; + uint64_t w1 = cross_registry_work_for(1000, &t1); + uint64_t w4 = cross_registry_work_for(4000, &t4); + printf(" cross registry work: 1000=%llu (types=%d) 4000=%llu (types=%d)\n", + (unsigned long long)w1, t1, (unsigned long long)w4, t4); + /* Every class registered exactly once (no duplicate placeholder). */ + ASSERT_EQ(t4 - t1, 3000); + ASSERT(w4 <= 5 * w1 + 1000); + PASS(); +} + extern const TSLanguage *tree_sitter_python(void); TEST(pylsp_issue1527_memo_allocation_failure_is_reported) { @@ -4885,6 +4938,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_issue1527_long_binary_chain_is_not_nesting); RUN_TEST(pylsp_issue1527_long_receiver_chain_is_not_nesting); RUN_TEST(pylsp_scale_work_is_linear); + RUN_TEST(pylsp_cross_registry_build_work_is_linear); /* Phase 2 — smoke */ RUN_TEST(pylsp_smoke_empty); RUN_TEST(pylsp_smoke_one_function); diff --git a/tests/test_py_lsp_scale.c b/tests/test_py_lsp_scale.c index 8654214bf2..1e11e4ae7f 100644 --- a/tests/test_py_lsp_scale.c +++ b/tests/test_py_lsp_scale.c @@ -111,72 +111,6 @@ TEST(pylsp_scale_linear_growth) { PASS(); } -/* Time cbm_py_build_cross_registry over n classes, each followed by two - * methods (extraction order): every method probes the registry for its - * receiver while the registry is still being built. */ -static double measure_cross_registry(int n_classes, int *out_types) { - CBMArena arena; - cbm_arena_init(&arena); - int n_defs = n_classes * 3; - CBMLSPDef *defs = (CBMLSPDef *)cbm_arena_calloc(&arena, (size_t)n_defs * sizeof(*defs)); - if (!defs) { - cbm_arena_destroy(&arena); - return -1.0; - } - for (int i = 0; i < n_classes; i++) { - CBMLSPDef *cls = &defs[i * 3]; - cls->qualified_name = cbm_arena_sprintf(&arena, "scale.mod.Cls%d", i); - cls->short_name = cls->qualified_name + strlen("scale.mod."); - cls->label = "Class"; - cls->def_module_qn = "scale.mod"; - cls->lang = CBM_LANG_PYTHON; - for (int j = 1; j <= 2; j++) { - CBMLSPDef *m = &defs[i * 3 + j]; - m->qualified_name = cbm_arena_sprintf(&arena, "%s.m%d", cls->qualified_name, j); - m->short_name = j == 1 ? "m1" : "m2"; - m->label = "Method"; - m->receiver_type = cls->qualified_name; - m->def_module_qn = "scale.mod"; - m->lang = CBM_LANG_PYTHON; - } - } - struct timespec t0, t1; - clock_gettime(CLOCK_MONOTONIC, &t0); - CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, n_defs); - clock_gettime(CLOCK_MONOTONIC, &t1); - if (out_types) - *out_types = reg ? reg->type_count : -1; - cbm_arena_destroy(&arena); - return elapsed_ms(t0, t1); -} - -/* The shared build registers defs one at a time into an unfinalized registry; - * a receiver probe that scans every type registered so far makes the build - * O(methods x types). 16x input: linear ~16x time, quadratic well over 100x. */ -TEST(pylsp_scale_cross_registry_build_linear) { - int ty_small = 0, ty_large = 0; - /* Best of three for the small size: it is short enough for host noise. */ - double t_small = measure_cross_registry(1000, &ty_small); - for (int k = 0; k < 2; k++) { - double t = measure_cross_registry(1000, NULL); - if (t >= 0.0 && t < t_small) - t_small = t; - } - double t_large = measure_cross_registry(16000, &ty_large); - printf(" cross registry: 1000 classes=%.1fms (types=%d) 16000 classes=%.1fms " - "(types=%d)\n", - t_small, ty_small, t_large, ty_large); - ASSERT(t_small >= 0.0 && t_large >= 0.0); - ASSERT_EQ(ty_large - ty_small, 15000); - if (t_small > 0.5) { - double ratio = t_large / t_small; - printf(" cross registry ratio 16000/1000: %.1fx (linear ~16x)\n", ratio); - ASSERT(ratio < 48.0); - } - PASS(); -} - SUITE(py_lsp_scale) { RUN_TEST(pylsp_scale_linear_growth); - RUN_TEST(pylsp_scale_cross_registry_build_linear); } From c21c42b4d6ce88da41fe040d8a6254405bdfbeff Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:30:41 +0200 Subject: [PATCH 35/97] test(cli): create the non-ASCII Windows profile through the wide API The Windows regression test for #2145 created its profile directory with test_mkdirp and read the result as a boolean. test_mkdirp returns 0 on success, so the test failed as soon as the directory existed and never reached the detection it was written to check. Inverting the check is not enough on its own. test_mkdirp uses cbm_mkdir, which is the narrow _mkdir on Windows. Under an ANSI code page such as 1252 it does not create the intended name: it creates a directory whose name is the UTF-8 bytes read as code-page characters. The old narrow stat finds that mangled directory and the new wide lookup correctly does not, so the test would pass without the fix and fail with it. th_mkdir_p goes through the wide API and creates the real name, the way a non-ASCII Windows profile actually looks. Since 2b329fe7, Claude Code is detected from settings.json, .claude.json or the claude CLI, through cbm_file_exists, which is already wide on Windows; a bare .claude directory no longer marks it as detected. The test now asserts Cursor, which is detected by its directory alone, so the assertion goes through dir_exists, the function this branch changes. Signed-off-by: Martin Vogel --- tests/test_cli.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_cli.c b/tests/test_cli.c index 42fba903e5..c72095f223 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -14597,19 +14597,24 @@ TEST(cli_detect_agents_finds_zed_in_roaming_appdata) { #endif #ifdef _WIN32 +/* #2145: a profile path outside the ANSI code page must stay visible to agent + * detection. The directory is created with th_mkdir_p, which goes through the + * wide-char API: test_mkdirp uses cbm_mkdir, the narrow _mkdir on Windows, + * which would create a code-page-mangled name instead of this one. Cursor is + * detected by its directory alone, so the assertion goes through dir_exists. */ TEST(cli_detect_agents_handles_non_ascii_windows_profile) { char home[1024]; snprintf(home, sizeof(home), "%s/cbm-用户-profile", cbm_tmpdir()); test_rmdir_r(home); - char claude_dir[1024]; - snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", home); - if (!test_mkdirp(claude_dir)) + char cursor_dir[1024]; + snprintf(cursor_dir, sizeof(cursor_dir), "%s/.cursor", home); + if (th_mkdir_p(cursor_dir) != 0) FAIL("could not create non-ASCII Windows profile path"); cbm_detected_agents_t agents = cbm_detect_agents(home); test_rmdir_r(home); - if (!agents.claude_code) - FAIL("Claude detection must support non-ASCII Windows profile paths"); + if (!agents.cursor) + FAIL("agent detection must support non-ASCII Windows profile paths"); PASS(); } #endif From b95b4033ea1a1befbd660e7a1917de059da8f705 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 21:14:50 +0200 Subject: [PATCH 36/97] fix(mcp): rank every BM25 candidate when post-window filters would hide them file_pattern, kind and namespace are applied after the 2000-row BM25 candidate window: unlike the label they cannot go into the FTS5 MATCH. With a common query token and a narrow filter the window held none of the filtered rows, and search_graph answered 0 rows -- or a few rows of an under-counted total -- although matching nodes exist. An unlabelled query whose window held only File/Folder/Variable/Project rows had the same failure. When the window is saturated and such a filter is set, or the window held no row passing the filters, the query is now ranked over every candidate (inner LIMIT -1): the FTS-first subquery plan stays, the extra work is linear in the token's match count (the saturation probe already orders every match), and the total is exact. The decision depends only on the query, never on the page, so offset pages stay consistent. Unfiltered and label-only queries that find rows keep the window and are byte-identical. The filter binds, the count query and the saturation probe move into helpers shared by the ranked and the count query. Measured on Django, meilisearch and kubernetes indexes (9 / 9 / 24 filtered queries): zero results 6 -> 0, 6 -> 0, 23 -> 0; rows returned 52 -> 156, 3 -> 303, 19 -> 807; exact totals 0 -> 9, 0 -> 9, 0 -> 24. The graph is unchanged (search-only change). Refs #2386 Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 228 +++++++++++++++++++++++++---------------------- tests/test_mcp.c | 152 +++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 107 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index ad93101865..869df5cc74 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -4019,6 +4019,8 @@ enum { * and then joining/filtering/re-ranking those, we bound all work to O(N) where * N = BM25_INNER_LIMIT rather than the full match set size. */ BM25_INNER_LIMIT = 2000, + /* SQLite reads a negative LIMIT as "no limit": rank every candidate. */ + BM25_INNER_ALL = -1, /* Longest label spliced into the MATCH pre-filter; real labels are short * identifiers, so anything longer simply skips the pre-filter. */ BM25_LABEL_MAX = 64, @@ -4374,6 +4376,96 @@ static char *bm25_render(const bm25_output_row_t *rows, int returned, int total, return json; } +/* The MATCH expression and the filters the ranked and the count query share. + * One binder for both keeps them on identical parameters. */ +typedef struct { + const char *fts_query; + const char *project; + const char *file_like; + const char *label; + const char *kind; + const char *ns; + double label_weight; +} bm25_filters_t; + +static void bm25_bind_opt_text(sqlite3_stmt *st, int idx, const char *value) { + if (value && value[0]) { + sqlite3_bind_text(st, idx, value, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + } else { + sqlite3_bind_null(st, idx); + } +} + +static void bm25_bind_filters(sqlite3_stmt *st, const bm25_filters_t *f) { + sqlite3_bind_text(st, BM25_BIND_QUERY, f->fts_query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + sqlite3_bind_text(st, BM25_BIND_PROJECT, f->project, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + bm25_bind_opt_text(st, BM25_BIND_FILE, f->file_like); + bm25_bind_opt_text(st, BM25_BIND_LABEL, f->label); + bm25_bind_opt_text(st, BM25_BIND_KIND, f->kind); + bm25_bind_opt_text(st, BM25_BIND_NS, f->ns); + sqlite3_bind_double(st, BM25_BIND_LABEL_WEIGHT, f->label_weight); +} + +/* The top-candidate window is a performance ceiling, not an exact-total + * boundary. Probe one candidate beyond it so a broad query never presents a + * window-local count as the complete match count. The probe shares the + * label-scoped MATCH but is otherwise global to the FTS table; saturation is + * therefore conservatively reported even when later project/path filters + * might discard the hidden candidates. */ +static bool bm25_window_saturated(sqlite3 *db, const char *fts_query) { + enum { PROBE_BIND_OFFSET = 2 }; + const char *probe_sql = "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1 " + "ORDER BY bm25(nodes_fts), rowid LIMIT 1 OFFSET ?2"; + sqlite3_stmt *probe = NULL; + if (sqlite3_prepare_v2(db, probe_sql, BM25_SQL_AUTO_LEN, &probe, NULL) != SQLITE_OK) { + return true; + } + sqlite3_bind_text(probe, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + sqlite3_bind_int(probe, PROBE_BIND_OFFSET, BM25_INNER_LIMIT); + bool saturated = sqlite3_step(probe) != SQLITE_DONE; + sqlite3_finalize(probe); + return saturated; +} + +/* Count the candidates that pass the filters among the top `inner_limit` + * (BM25_INNER_ALL: among all of them), with the ranked query's subquery shape + * so FTS5 drives the scan here too. */ +static int bm25_count_candidates(sqlite3 *db, const bm25_filters_t *f, int inner_limit) { + const char *count_sql = + "SELECT COUNT(*) FROM (" + " SELECT fts.rowid FROM (" + " SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1" + " ORDER BY " BM25_WEIGHTS " LIMIT ?3" + " ) fts " + " JOIN nodes n ON n.id = fts.rowid " + " WHERE n.project = ?2 " + /* MIRRORS the ranked query's filter verbatim — same weights, same + * label exclusions. Changing one alone reports a total that does + * not describe the rows returned. */ + " AND " BM25_UNLABELLED_EXCLUSION " " + " AND (?6 IS NULL OR n.file_path LIKE ?6)" + " AND (?7 IS NULL OR n.label = ?7)" + " AND (?9 IS NULL OR (CASE WHEN json_valid(n.properties) " + " THEN json_extract(n.properties,'$.kind') END) = ?9)" + " AND (?10 IS NULL OR (CASE WHEN json_valid(n.properties) " + " THEN json_extract(n.properties,'$.obj_key') END) " + " LIKE ('%/%/' || replace(replace(replace(?10,'\\','\\\\'),'%','\\%')," + " '_','\\_') || '/%') ESCAPE '\\')" + ")"; + int total = 0; + sqlite3_stmt *cs = NULL; + if (sqlite3_prepare_v2(db, count_sql, BM25_SQL_AUTO_LEN, &cs, NULL) != SQLITE_OK) { + return 0; + } + bm25_bind_filters(cs, f); + sqlite3_bind_int(cs, BM25_BIND_LIMIT, inner_limit); + if (sqlite3_step(cs) == SQLITE_ROW) { + total = sqlite3_column_int(cs, 0); + } + sqlite3_finalize(cs); + return total; +} + /* Run the BM25 full-text search path and return the JSON result string. * Returns NULL if FTS5 is unavailable or the query produced no usable tokens, * in which case the caller falls back to the regex-based search path. */ @@ -4461,117 +4553,39 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu free(file_like); return NULL; } - sqlite3_bind_text(stmt, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - sqlite3_bind_text(stmt, BM25_BIND_PROJECT, project, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + const bm25_filters_t filters = {.fts_query = fts_query, + .project = project, + .file_like = file_like, + .label = label, + .kind = kind, + .ns = ns, + .label_weight = label_weight}; + bm25_bind_filters(stmt, &filters); sqlite3_bind_int(stmt, BM25_BIND_LIMIT, limit > 0 ? limit : BM25_DEFAULT_LIMIT); sqlite3_bind_int(stmt, BM25_BIND_OFFSET, offset > 0 ? offset : 0); - sqlite3_bind_int(stmt, BM25_BIND_INNER, BM25_INNER_LIMIT); - if (file_like) { - sqlite3_bind_text(stmt, BM25_BIND_FILE, file_like, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(stmt, BM25_BIND_FILE); - } - if (label && label[0]) { - sqlite3_bind_text(stmt, BM25_BIND_LABEL, label, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(stmt, BM25_BIND_LABEL); - } sqlite3_bind_text(stmt, BM25_BIND_EXACT, query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - if (kind && kind[0]) { - sqlite3_bind_text(stmt, BM25_BIND_KIND, kind, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(stmt, BM25_BIND_KIND); - } - if (ns && ns[0]) { - sqlite3_bind_text(stmt, BM25_BIND_NS, ns, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(stmt, BM25_BIND_NS); - } - sqlite3_bind_double(stmt, BM25_BIND_LABEL_WEIGHT, label_weight); - - /* Count hits within the same inner-limit window — capped at BM25_INNER_LIMIT. - * Uses the identical subquery structure so the FTS5 early-exit applies here too. */ - int total = 0; - { - const char *count_sql = - "SELECT COUNT(*) FROM (" - " SELECT fts.rowid FROM (" - " SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1" - " ORDER BY " BM25_WEIGHTS " LIMIT ?3" - " ) fts " - " JOIN nodes n ON n.id = fts.rowid " - " WHERE n.project = ?2 " - /* MIRRORS the ranked query's filter verbatim — same weights, same - * label exclusions. Changing one alone reports a total that does - * not describe the rows returned. */ - " AND " BM25_UNLABELLED_EXCLUSION " " - " AND (?6 IS NULL OR n.file_path LIKE ?6)" - " AND (?7 IS NULL OR n.label = ?7)" - " AND (?9 IS NULL OR (CASE WHEN json_valid(n.properties) " - " THEN json_extract(n.properties,'$.kind') END) = ?9)" - " AND (?10 IS NULL OR (CASE WHEN json_valid(n.properties) " - " THEN json_extract(n.properties,'$.obj_key') END) " - " LIKE ('%/%/' || replace(replace(replace(?10,'\\','\\\\'),'%','\\%')," - " '_','\\_') || '/%') ESCAPE '\\')" - ")"; - sqlite3_stmt *cs = NULL; - if (sqlite3_prepare_v2(db, count_sql, BM25_SQL_AUTO_LEN, &cs, NULL) == SQLITE_OK) { - sqlite3_bind_text(cs, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - sqlite3_bind_text(cs, BM25_BIND_PROJECT, project, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - sqlite3_bind_int(cs, BM25_BIND_LIMIT, BM25_INNER_LIMIT); - sqlite3_bind_double(cs, BM25_BIND_LABEL_WEIGHT, label_weight); - if (file_like) { - sqlite3_bind_text(cs, BM25_BIND_FILE, file_like, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(cs, BM25_BIND_FILE); - } - if (label && label[0]) { - sqlite3_bind_text(cs, BM25_BIND_LABEL, label, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(cs, BM25_BIND_LABEL); - } - if (kind && kind[0]) { - sqlite3_bind_text(cs, BM25_BIND_KIND, kind, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(cs, BM25_BIND_KIND); - } - if (ns && ns[0]) { - sqlite3_bind_text(cs, BM25_BIND_NS, ns, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(cs, BM25_BIND_NS); - } - if (sqlite3_step(cs) == SQLITE_ROW) { - total = sqlite3_column_int(cs, 0); - } - sqlite3_finalize(cs); - } - } - /* The top-candidate window is a performance ceiling, not an exact-total - * boundary. Probe one candidate beyond it so a broad query never presents - * a window-local count as the complete match count. The probe shares the - * label-scoped MATCH but is otherwise global to the FTS table; saturation - * is therefore conservatively reported even when later project/path - * filters might discard the hidden candidates. */ - bool candidate_window_saturated = true; - { - const char *probe_sql = "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1 " - "ORDER BY bm25(nodes_fts), rowid LIMIT 1 OFFSET ?2"; - sqlite3_stmt *probe = NULL; - if (sqlite3_prepare_v2(db, probe_sql, BM25_SQL_AUTO_LEN, &probe, NULL) == SQLITE_OK) { - sqlite3_bind_text(probe, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - sqlite3_bind_int(probe, BM25_BIND_PROJECT, BM25_INNER_LIMIT); - int probe_step = sqlite3_step(probe); - candidate_window_saturated = probe_step != SQLITE_DONE; - sqlite3_finalize(probe); - } - } + /* file_pattern, kind and namespace cannot be put into the MATCH the way + * the label is, so they filter AFTER the candidate window. When more + * candidates exist beyond it, a narrow filter can find none of its rows in + * the top BM25_INNER_LIMIT and the search answered 0 rows although + * matching nodes exist. A saturated window under such a filter -- or one + * that held no row passing the filters at all -- is therefore ranked over + * every candidate instead: the FTS-first subquery plan stays, the extra + * work is linear in the token's match count (the saturation probe already + * orders every match), and the total becomes exact. The decision depends + * only on the query, never on the page, so offset pages stay consistent. + * Unfiltered and label-only queries that find rows keep the window. */ + bool saturated = bm25_window_saturated(db, fts_query); + bool post_window_filter = file_like || (kind && kind[0]) || (ns && ns[0]); + int inner_limit = saturated && post_window_filter ? BM25_INNER_ALL : BM25_INNER_LIMIT; + int total = bm25_count_candidates(db, &filters, inner_limit); + if (saturated && total == 0 && inner_limit != BM25_INNER_ALL) { + inner_limit = BM25_INNER_ALL; + total = bm25_count_candidates(db, &filters, inner_limit); + } + bool candidate_window_saturated = saturated && inner_limit != BM25_INNER_ALL; + sqlite3_bind_int(stmt, BM25_BIND_INNER, inner_limit); int row_cap = limit > 0 ? limit : BM25_DEFAULT_LIMIT; bm25_output_row_t *rows = diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 28d1946b7a..2ef16fefe2 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -4729,6 +4729,156 @@ TEST(tool_search_graph_bm25_zero_hint_names_the_label) { PASS(); } +/* file_pattern, kind and namespace filter after the BM25 candidate window + * (they cannot be put into the MATCH like the label). 2001 same-label nodes + * with a short path fill the window ahead of 3 that rank strictly lower (long + * path) and are the only ones passing each filter, so a windowed search + * answered 0 rows. A saturated window under such a filter is ranked over + * every candidate: all 3 rows, an exact total, consistent offset pages. */ +static void bm25_post_window_fixture(cbm_store_t *store, const char *project) { + cbm_store_upsert_project(store, project, "/tmp/bm25-post-window"); + for (int i = 0; i < 2004; i++) { + bool rare = i >= 2001; + char qn[64]; + snprintf(qn, sizeof(qn), "bm25-post-window.%s_%04d", rare ? "rare" : "fn", i); + cbm_node_t node = { + .project = project, + .label = "Function", + .name = "postneedle", + .qualified_name = qn, + .file_path = rare ? "deeply/nested/package/layout/registry.c" : "many.c", + .start_line = i + 1, + .end_line = i + 1, + .properties_json = + rare ? "{\"kind\":\"Deployment\",\"obj_key\":\"apps/Deployment/prod/web\"}" : NULL}; + cbm_store_upsert_node(store, &node); + } + cbm_store_exec(store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');"); + cbm_store_exec(store, "INSERT INTO nodes_fts(rowid, name, qualified_name, label, " + "file_path) SELECT id, cbm_camel_split(name), qualified_name, " + "label, file_path FROM nodes;"); +} + +static char *bm25_post_window_search(cbm_mcp_server_t *srv, const char *filter_json, int limit, + int offset) { + char req[512]; + snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":2424,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"bm25-post-window\",\"query\":\"postneedle\"%s," + "\"limit\":%d,\"offset\":%d,\"format\":\"json\"}}}", + filter_json, limit, offset); + char *resp = cbm_mcp_server_handle(srv, req); + char *inner = resp ? extract_text_content(resp) : NULL; + free(resp); + return inner; +} + +TEST(tool_search_graph_bm25_post_window_filters_rank_every_candidate) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_mcp_server_set_project(srv, "bm25-post-window"); + bm25_post_window_fixture(store, "bm25-post-window"); + + static const char *const filters[] = { + ",\"file_pattern\":\"deeply/nested\"", + ",\"kind\":\"Deployment\"", + ",\"namespace\":\"prod\"", + }; + for (size_t i = 0; i < sizeof(filters) / sizeof(filters[0]); i++) { + char *inner = bm25_post_window_search(srv, filters[i], 10, 0); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + ASSERT_NOT_NULL(strstr(inner, "\"bm25-post-window.rare_2001\"")); + ASSERT_NOT_NULL(strstr(inner, "\"bm25-post-window.rare_2003\"")); + ASSERT_NULL(strstr(inner, "\"bm25-post-window.fn_")); + ASSERT_NOT_NULL(strstr(inner, "\"total\":3,")); + ASSERT_NOT_NULL(strstr(inner, "\"total_relation\":\"eq\"")); + ASSERT_NULL(strstr(inner, "candidate_window_saturated")); + free(inner); + } + + /* Offset pages over the exhaustive ranking are disjoint and complete. */ + char *page1 = bm25_post_window_search(srv, filters[0], 2, 0); + char *page2 = bm25_post_window_search(srv, filters[0], 2, 2); + ASSERT_NOT_NULL(page1); + ASSERT_NOT_NULL(page2); + ASSERT_NOT_NULL(strstr(page1, "\"returned\":2,")); + ASSERT_NOT_NULL(strstr(page2, "\"returned\":1,")); + ASSERT_NOT_NULL(strstr(page2, "\"total\":3,")); + for (int i = 2001; i < 2004; i++) { + char qn[64]; + snprintf(qn, sizeof(qn), "\"bm25-post-window.rare_%04d\"", i); + ASSERT_TRUE((strstr(page1, qn) != NULL) != (strstr(page2, qn) != NULL)); + } + free(page1); + free(page2); + + /* Without a post-window filter the window stays and says so. */ + char *inner = bm25_post_window_search(srv, "", 10, 0); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"total\":2000,")); + ASSERT_NOT_NULL(strstr(inner, "\"candidate_window_saturated\":true")); + free(inner); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* An unlabelled query whose whole candidate window is File/Folder/Variable/ + * Project rows (excluded from unlabelled ranking) found no row at all, while + * a matching definition sat just beyond the window: 2001 short Folder rows + * outrank one Function with a long path. A window with no row passing the + * filters is ranked over every candidate instead. */ +TEST(tool_search_graph_bm25_empty_window_ranks_every_candidate) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "bm25-empty-window"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/bm25-empty-window"), CBM_STORE_OK); + for (int i = 0; i < 2002; i++) { + bool def = i == 2001; + char qn[64]; + snprintf(qn, sizeof(qn), "bm25-empty-window.%s_%04d", def ? "def" : "dir", i); + cbm_node_t node = {.project = project, + .label = def ? "Function" : "Folder", + .name = "foldneedle", + .qualified_name = qn, + .file_path = def ? "deeply/nested/package/layout/registry.c" : "d", + .start_line = i + 1, + .end_line = i + 1}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + } + ASSERT_EQ(cbm_store_exec(store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');"), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_exec(store, "INSERT INTO nodes_fts(rowid, name, qualified_name, label, " + "file_path) SELECT id, cbm_camel_split(name), qualified_name, " + "label, file_path FROM nodes;"), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":2425,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"bm25-empty-window\",\"query\":\"foldneedle\",\"limit\":10," + "\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"bm25-empty-window.def_2001\",\"Function\"")); + ASSERT_NULL(strstr(inner, "\"Folder\"")); + ASSERT_NOT_NULL(strstr(inner, "\"total\":1,")); + ASSERT_NOT_NULL(strstr(inner, "\"total_relation\":\"eq\"")); + free(inner); + + cbm_mcp_server_free(srv); + PASS(); +} + /* The definition whose NAME is the query ranks first: the `Table` class above * its own methods and above test methods that repeat "table" three times; the * `get_object_or_404` function above the test methods that contain it. */ @@ -25336,6 +25486,8 @@ SUITE(mcp) { RUN_TEST(tool_search_graph_bm25_label_scope_keeps_rank_order); RUN_TEST(tool_search_graph_bm25_serves_unranked_labels_when_named); RUN_TEST(tool_search_graph_bm25_zero_hint_names_the_label); + RUN_TEST(tool_search_graph_bm25_post_window_filters_rank_every_candidate); + RUN_TEST(tool_search_graph_bm25_empty_window_ranks_every_candidate); RUN_TEST(tool_search_graph_bm25_ranks_exact_name_first); RUN_TEST(tool_search_graph_rejects_bm25_and_semantic_query_together); RUN_TEST(tool_search_graph_semantic_ceiling_never_emits_unusable_continuation); From 0b81154813a46977eb6d4c568b117a36e0c7dc73 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:42:19 +0200 Subject: [PATCH 37/97] ci: bump the CodeQL actions to v4.39.0 together and group their Dependabot updates Dependabot opened one PR per CodeQL action (#2504 init, #2505 analyze, #2506 upload-sarif), each moving to 4.38.2. Merging them one at a time would leave init and analyze on different versions in between. This moves all three to v4.39.0 (6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee, the commit the v4.39.0 tag points to) in one change, and adds a Dependabot group so future CodeQL Action updates arrive as a single PR. Signed-off-by: Martin Vogel --- .github/dependabot.yml | 6 ++++++ .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5ace4600a1..c6bbd96e10 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,9 @@ updates: directory: "/" schedule: interval: "weekly" + groups: + # init and analyze must run the same CodeQL Action version within a job, and + # upload-sarif is kept in step with them, so one PR bumps all three. + codeql-action: + patterns: + - "github/codeql-action*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f5cfb59d6d..ae9b5f861c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -113,7 +113,7 @@ jobs: run: scripts/ci/codeql-retrace-vendored.sh "$RUNNER_TEMP/codeql-retrace.txt" - name: Initialize CodeQL - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -128,7 +128,7 @@ jobs: run: scripts/build.sh --reuse-vendored --retrace-from "$RUNNER_TEMP/codeql-retrace.txt" - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fb0a52bbc7..9f54249ae5 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -33,6 +33,6 @@ jobs: publish_results: true - name: Upload SARIF results - uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 + uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: sarif_file: results.sarif From 4c09f7c70cf083bd213921772de954c24652049c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 01:43:14 +0200 Subject: [PATCH 38/97] feat(plugin): Claude Code plugin marketplace, discovery slice Make this repository a Claude Code plugin marketplace so users can find and add the server from inside Claude Code: claude plugin marketplace add DeusData/codebase-memory-mcp claude plugin install codebase-memory@codebase-memory This is slice 1 of #1188: discovery only. The plugin registers the MCP server and nothing else; the skill, the graph agents and the context hooks still come from `codebase-memory-mcp install`. - .claude-plugin/marketplace.json: one plugin, sourced from ./plugin. - plugin/.claude-plugin/plugin.json: version pinned to the current release. Without a pinned version every commit to main would be a new plugin version for installed users. - plugin/.mcp.json: stdio server `npx -y codebase-memory-mcp@0.12.1`. The pin keeps main from advertising a server version npm does not have; the npm package downloads the release binary on first start and verifies it against the release checksums. - .gitignore: the root .mcp.json stays ignored; plugin/.mcp.json is published. - README: install section, and "use one or the other": with both the plugin and `install`, Claude Code runs the server twice under two tool namespaces. - tests/test_version_metadata_contract.sh: both plugin files are release-tracking surfaces; a new claim form reads the npm package spec (`codebase-memory-mcp@X.Y.Z`), and the unregistered-surface sweep now covers plugin/ and .claude-plugin/. The post-release sync that bumps server.json must bump these pins too, or this contract goes red. No language count is stated anywhere in the plugin metadata, so the language-count contract has nothing new to track. Refs #1188 Co-authored-by: Kody Signed-off-by: Martin Vogel --- .claude-plugin/marketplace.json | 15 +++++++++++++++ .gitignore | 2 ++ README.md | 24 ++++++++++++++++++++++++ plugin/.claude-plugin/plugin.json | 9 +++++++++ plugin/.mcp.json | 9 +++++++++ tests/test_version_metadata_contract.sh | 16 +++++++++++++--- 6 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 plugin/.claude-plugin/plugin.json create mode 100644 plugin/.mcp.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000000..f44b5f3c24 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "codebase-memory", + "description": "The codebase-memory-mcp code knowledge graph server, as a Claude Code plugin.", + "owner": { + "name": "DeusData", + "url": "https://github.com/DeusData" + }, + "plugins": [ + { + "name": "codebase-memory", + "source": "./plugin", + "description": "Codebase knowledge graph for AI agents: sub-ms queries, 99% fewer tokens." + } + ] +} diff --git a/.gitignore b/.gitignore index 1533c93e00..c388fa360f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ Thumbs.db # MCP config (user-local, generated by install command) .mcp.json +# ...except the Claude Code plugin's server definition, which is published +!plugin/.mcp.json # MCP Registry auth tokens .mcpregistry_* diff --git a/README.md b/README.md index 7cdac83bf1..8d581a0ac3 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,30 @@ Working in a clone? Use `.` in place of the flake URL, e.g. `nix run .#codebase- You: "Install this MCP server: https://github.com/DeusData/codebase-memory-mcp" ``` +### Install via Claude Code plugin + +This repository is also a Claude Code plugin marketplace, so you can find and +add the server from inside Claude Code: + +```bash +claude plugin marketplace add DeusData/codebase-memory-mcp +claude plugin install codebase-memory@codebase-memory +``` + +The plugin registers the MCP server only. It starts the server with +`npx -y codebase-memory-mcp@`, pinned to the current release; the first +start downloads that release's binary through the npm package, which verifies +it against the release checksums. The skill, the three graph agents and the +context hooks are not part of the plugin: they come from +`codebase-memory-mcp install`. + +Use one or the other. `install` registers the same server for Claude Code, so +with both you run it twice, under two tool namespaces +(`mcp__codebase-memory-mcp__*` and +`mcp__plugin_codebase-memory_codebase-memory-mcp__*`). Choose `install` for the +full Claude Code setup; choose the plugin to add just the server without +running an installer. + ### Build from Source
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000000..0960a2b718 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "codebase-memory", + "version": "0.12.1", + "description": "Codebase knowledge graph for AI agents: sub-ms queries, 99% fewer tokens.", + "author": { + "name": "DeusData", + "url": "https://github.com/DeusData" + } +} diff --git a/plugin/.mcp.json b/plugin/.mcp.json new file mode 100644 index 0000000000..07fbe13c1e --- /dev/null +++ b/plugin/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "codebase-memory-mcp": { + "type": "stdio", + "command": "npx", + "args": ["-y", "codebase-memory-mcp@0.12.1"] + } + } +} diff --git a/tests/test_version_metadata_contract.sh b/tests/test_version_metadata_contract.sh index b74203bf6b..54d7879442 100644 --- a/tests/test_version_metadata_contract.sh +++ b/tests/test_version_metadata_contract.sh @@ -109,6 +109,8 @@ SURFACES=( "pkg/chocolatey/tools/chocolateyInstall.ps1|release" "pkg/scoop/codebase-memory-mcp.json|release" "pkg/homebrew/Formula/codebase-memory-mcp.rb|release" + "plugin/.claude-plugin/plugin.json|release" + "plugin/.mcp.json|release" "pkg/aur/PKGBUILD|pin:0.8.1" "pkg/aur/.SRCINFO|pin:0.8.1" ) @@ -163,6 +165,12 @@ versions_in() { # cannot see it, because the version keyword is in the function NAME. grep -oE 'return "[0-9]+\.[0-9]+\.[0-9]+"' "$path" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' + # npm package-spec form: the Claude Code plugin launches the server as + # `npx -y codebase-memory-mcp@X.Y.Z`, and that pin is the version every + # plugin user actually runs. No declaration keyword sits next to it, so + # without this form plugin/.mcp.json reads as a surface with no claim. + grep -oE 'codebase-memory-mcp@[0-9]+\.[0-9]+\.[0-9]+' "$path" | + grep -oE '[0-9]+\.[0-9]+\.[0-9]+$' } | sort -u } @@ -280,7 +288,9 @@ fi # this contract exists to prevent, and every check above would still pass. The # sweep pattern is deliberately BROADER than the extractors in section 3: if the # two shared a regex, a claim form the extractors cannot see would be invisible -# to the sweep as well. +# to the sweep as well. The Claude Code plugin tree (plugin/, .claude-plugin/) is +# swept too: a later plugin file that pins `codebase-memory-mcp@X.Y.Z` (a hook +# command, say) must be registered here, or it would go stale unchecked. while IFS= read -r path; do [[ -z "$path" ]] && continue for entry in "${SURFACES[@]}"; do @@ -293,8 +303,8 @@ while IFS= read -r path; do "SURFACES so it stays in step with the release, or to EXEMPT with a" \ "note saying what its number actually is." done < <( - git grep -IlE '(version|pkgver|releases/(download|tag)/v)[^0-9]{0,16}[0-9]+\.[0-9]+\.[0-9]+|return "[0-9]+\.[0-9]+\.[0-9]+"' \ - -- server.json pkg tests/test_version_metadata_contract.sh 2>/dev/null || true + git grep -IlE '(version|pkgver|releases/(download|tag)/v)[^0-9]{0,16}[0-9]+\.[0-9]+\.[0-9]+|return "[0-9]+\.[0-9]+\.[0-9]+"|codebase-memory-mcp@[0-9]+\.[0-9]+\.[0-9]+' \ + -- server.json pkg plugin .claude-plugin tests/test_version_metadata_contract.sh 2>/dev/null || true ) if ((failures > 0)); then From ad7899399bf4ec6f1dfcaf195bba99160908ccc7 Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Wed, 21 Oct 2026 13:23:06 +0000 Subject: [PATCH 39/97] fix(routes): read Go 1.22 ServeMux method and host patterns Since Go 1.22 a net/http ServeMux pattern may name a method and a host before the path: mux.HandleFunc("POST /v1/payments", h), "GET /v1/payments/{id}", "api.example.com/v1/x". Every route registration gate wanted the first argument to open with a slash (the extractor's handler capture, find_route_path_in_args in the parallel pass, and the three gates in the sequential pass), so such a registration minted no Route and no HANDLES edge, while the same file written with "/v1/payments" had both. cbm_service_pattern_mux_split reads the "[METHOD ][HOST]/[PATH]" grammar. For Go files only, the path of such a pattern now registers the route, the Route takes the pattern's method when the callee names none (Handle/HandleFunc are ANY), and a host is dropped from the path. Both passes share cbm_pipeline_mux_route_path and cbm_pipeline_route_reg_method so their Routes stay identical. Other languages and plain "/path" patterns take the unchanged path. Fixes #2537 Signed-off-by: Gaurav Dubey --- internal/cbm/extract_calls.c | 9 ++- internal/cbm/service_patterns.c | 44 +++++++++++ internal/cbm/service_patterns.h | 11 +++ src/pipeline/pass_calls.c | 30 ++++++-- src/pipeline/pass_parallel.c | 37 ++++++++- src/pipeline/pipeline_internal.h | 15 ++++ tests/test_extraction.c | 35 +++++++++ tests/test_infrascan.c | 34 +++++++++ tests/test_pipeline.c | 125 +++++++++++++++++++++++++++++++ 9 files changed, 327 insertions(+), 13 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 4eb02659c4..f16b8ecf88 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -4553,8 +4553,13 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML call.first_string_arg = route_path_with_group_prefix(ctx, node, call.callee_name, call.first_string_arg); /* A slashless registration's string action would only be - * guessed at (#1146): mint its Route without a handler. */ - if (call.first_string_arg && call.first_string_arg[0] == '/' && + * guessed at (#1146): mint its Route without a handler. A Go + * ServeMux pattern names its method or host before the slash + * ("POST /v1/x", #2537) and keeps its handler. */ + if (call.first_string_arg && + (call.first_string_arg[0] == '/' || + (ctx->language == CBM_LANG_GO && + cbm_service_pattern_mux_split(call.first_string_arg, NULL, 0) != NULL)) && !(laravel_uri && php_route_action_is_string(node))) { call.second_arg_name = extract_handler_arg(ctx, args); } diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 7d428b8e31..1eaa509414 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -1183,6 +1183,50 @@ const char *cbm_service_pattern_route_method(const char *callee_name) { return NULL; } +static bool mux_is_blank(char ch) { + return ch == ' ' || ch == '\t'; +} + +const char *cbm_service_pattern_mux_split(const char *pattern, char *method, size_t method_sz) { + if (method && method_sz > 0) { + method[0] = '\0'; + } + if (!pattern || pattern[0] == '/') { + return NULL; + } + /* METHOD: Go cuts it at the first space or tab and trims the rest. */ + const char *rest = pattern; + size_t method_len = 0; + while (pattern[method_len] >= 'A' && pattern[method_len] <= 'Z') { + method_len++; + } + if (method_len > 0 && mux_is_blank(pattern[method_len])) { + rest = pattern + method_len; + while (mux_is_blank(*rest)) { + rest++; + } + } else { + method_len = 0; + } + /* HOST: everything before the path's slash, with no blank or line break. */ + const char *slash = rest; + while (*slash && *slash != '/' && !mux_is_blank(*slash) && *slash != '\n' && *slash != '\r') { + slash++; + } + if (*slash != '/' || (method_len == 0 && slash == rest)) { + return NULL; + } + if (method_len > 0 && method) { + if (method_len >= method_sz) { + method[0] = '\0'; + return NULL; + } + memcpy(method, pattern, method_len); + method[method_len] = '\0'; + } + return slash; +} + const char *cbm_service_pattern_broker(const char *resolved_qn) { if (!resolved_qn) { return NULL; diff --git a/internal/cbm/service_patterns.h b/internal/cbm/service_patterns.h index f06c85571a..59f3ea0afa 100644 --- a/internal/cbm/service_patterns.h +++ b/internal/cbm/service_patterns.h @@ -84,6 +84,17 @@ const char *cbm_service_pattern_http_method(const char *callee_name); * Returns NULL if not a known route registration method. */ const char *cbm_service_pattern_route_method(const char *callee_name); +/* Go 1.22+ net/http ServeMux pattern "[METHOD ][HOST]/[PATH]" (#2537): + * "POST /v1/x", "GET api.example.com/v1/x", "api.example.com/v1/x". Returns a + * pointer to PATH's leading slash inside `pattern`, or NULL when the pattern + * names neither a method nor a host before a slash (a plain "/v1/x" is NULL + * too: there is nothing to split). METHOD is a run of uppercase letters ended + * by spaces or tabs; it is copied to `method` (NUL-terminated, "" when the + * pattern names none) when `method` is non-NULL; a method that does not fit + * `method_sz` then makes the pattern NULL. The grammar is Go's: callers gate + * on the file language. */ +const char *cbm_service_pattern_mux_split(const char *pattern, char *method, size_t method_sz); + /* Get the broker name for an async QN (e.g., "pubsub" from a Pub/Sub QN). * Returns NULL if not an async pattern. */ const char *cbm_service_pattern_broker(const char *resolved_qn); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index f3570904db..0e7069ab67 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -571,6 +571,21 @@ cbm_svc_kind_t cbm_pipeline_external_import_service(cbm_external_import_index_t return CBM_SVC_NONE; } +/* The path a route registration's first argument names: the argument itself + * when it opens with a slash, or in Go the path of a ServeMux pattern that + * names a method or host first ("POST /v1/x", #2537). NULL when it names no + * path. MUST match pass_parallel.c find_route_path_in_args. */ +static const char *route_reg_path(const CBMCall *call, CBMLanguage lang) { + const char *arg = call->first_string_arg; + if (!arg) { + return NULL; + } + if (arg[0] == '/') { + return arg; + } + return cbm_pipeline_mux_route_path(arg, lang); +} + /* Handle a route registration call: create Route node + HANDLES edge. * route_mount is the framework mount of the registering file ("/api" for a * Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */ @@ -578,10 +593,11 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca const cbm_gbuf_node_t *source_node, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, const char *route_mount, CBMLanguage lang) { - const char *method = cbm_service_pattern_route_method(call->callee_name); + char mux_method[CBM_SZ_16]; + const char *method = cbm_pipeline_route_reg_method(call, lang, mux_method, sizeof(mux_method)); char mounted[CBM_SZ_256]; const char *path = - cbm_laravel_mount_route(route_mount, call->first_string_arg, mounted, sizeof(mounted)); + cbm_laravel_mount_route(route_mount, route_reg_path(call, lang), mounted, sizeof(mounted)); char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY", @@ -815,7 +831,7 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, bool suppress_plain_calls, const char *route_mount, CBMLanguage lang) { cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name); - if (svc == CBM_SVC_ROUTE_REG && call->first_string_arg && call->first_string_arg[0] == '/') { + if (svc == CBM_SVC_ROUTE_REG && route_reg_path(call, lang) != NULL) { handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count, route_mount, lang); return; @@ -1042,8 +1058,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM * apps. Classify by callee suffix + path-shaped first arg, exactly * like the parallel path's callee_suffix fallback; without this the * sequential path minted zero Route nodes for such files. */ - if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg && - call->first_string_arg[0] == '/') { + if (cbm_service_pattern_route_method(call->callee_name) != NULL && + route_reg_path(call, lang) != NULL) { handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, route_mount, lang); return SKIP_ONE; @@ -1220,8 +1236,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM * unresolved-callee fallback above mints, and nothing else. Dropping * the whole call lost every GET registration in a mixed-language repo * while POST (no `post` to collide with) survived. */ - if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg && - call->first_string_arg[0] == '/') { + if (cbm_service_pattern_route_method(call->callee_name) != NULL && + route_reg_path(call, lang) != NULL) { handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, route_mount, lang); return SKIP_ONE; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 73ce742ad9..3801102955 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2263,13 +2263,41 @@ static bool is_route_path_shaped(const char *val) { return val && val[0] == '/' && !cbm_service_pattern_is_comment_text(val); } -static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler) { +const char *cbm_pipeline_mux_route_path(const char *arg, CBMLanguage lang) { + if (lang != CBM_LANG_GO || !arg || cbm_service_pattern_is_comment_text(arg)) { + return NULL; + } + return cbm_service_pattern_mux_split(arg, NULL, 0); +} + +const char *cbm_pipeline_route_reg_method(const CBMCall *call, CBMLanguage lang, char *buf, + size_t buf_sz) { + const char *method = cbm_service_pattern_route_method(call->callee_name); + if (method && strcmp(method, "ANY") != 0) { + return method; + } + if (cbm_pipeline_mux_route_path(call->first_string_arg, lang) && + cbm_service_pattern_mux_split(call->first_string_arg, buf, buf_sz) && buf[0] != '\0') { + return buf; + } + return method; +} + +static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler, + CBMLanguage lang) { *out_handler = NULL; /* 1. First string arg starting with / */ if (is_route_path_shaped(call->first_string_arg)) { *out_handler = call->second_arg_name; return call->first_string_arg; } + /* 1b. Go ServeMux pattern with a method or host before the path (#2537). + * MUST match pass_calls.c route_reg_path. */ + const char *mux_path = cbm_pipeline_mux_route_path(call->first_string_arg, lang); + if (mux_path) { + *out_handler = call->second_arg_name; + return mux_path; + } /* 2. Keyword args (prefix=, path=, route=, etc.) */ const char *found = NULL; for (int ai = 0; ai < call->arg_count && !found; ai++) { @@ -2428,7 +2456,8 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, const char **ik, const char **iv, int ic, const char *route_mount, CBMLanguage lang) { - const char *method = cbm_service_pattern_route_method(call->callee_name); + char mux_method[CBM_SZ_16]; + const char *method = cbm_pipeline_route_reg_method(call, lang, mux_method, sizeof(mux_method)); char mounted[CBM_SZ_256]; route_path = cbm_laravel_mount_route(route_mount, route_path, mounted, sizeof(mounted)); char rqn[CBM_ROUTE_QN_SIZE]; @@ -2813,7 +2842,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, if (svc == CBM_SVC_ROUTE_REG) { const char *handler_ref = NULL; - const char *route_path = find_route_path_in_args(call, &handler_ref); + const char *route_path = find_route_path_in_args(call, &handler_ref, lang); if (route_path) { emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, registry, main_gbuf, imp_keys, imp_vals, imp_count, route_mount, @@ -2876,7 +2905,7 @@ static void emit_xlang_refused_route(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *so return; } const char *handler_ref = NULL; - const char *route_path = find_route_path_in_args(call, &handler_ref); + const char *route_path = find_route_path_in_args(call, &handler_ref, lang); if (route_path) { emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, registry, main_gbuf, imp_keys, imp_vals, imp_count, route_mount, lang); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index fb5b724d64..d6882067ed 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -787,6 +787,21 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje * behind both passes' HTTP_CALLS / ASYNC_CALLS props (pass_parallel.c). */ void cbm_pipeline_close_call_props(char *props, int n, const CBMCall *call, bool with_line); +/* Go 1.22 ServeMux registrations may name a method and a host before the path + * ("POST /v1/x", "GET api.example.com/v1/x", #2537). For a Go file, the path + * inside such a route-registration argument; NULL for any other language and + * for an argument that is no such pattern (a plain "/v1/x" included). The one + * reader of that grammar behind both passes (pass_parallel.c). */ +const char *cbm_pipeline_mux_route_path(const char *arg, CBMLanguage lang); + +/* The method of a route registration's Route: the verb its callee names + * (`router.GET`), else the method its Go ServeMux pattern names + * (`mux.HandleFunc("POST /v1/x", h)`, #2537), else the callee's own answer + * (ANY or NULL). `buf` (CBM_SZ_16) holds a pattern method. Shared by both + * passes so their Routes stay byte-identical (pass_parallel.c). */ +const char *cbm_pipeline_route_reg_method(const CBMCall *call, CBMLanguage lang, char *buf, + size_t buf_sz); + /* Per-file index of the names a file's imports bind onto EXTERNAL modules: * local name -> the binding import. Built lazily, on the first call that * asks, from the file's extraction imports and its IMPORTS edges; one hash diff --git a/tests/test_extraction.c b/tests/test_extraction.c index e1c1e4491f..e94aa73c41 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -6207,6 +6207,40 @@ TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249) { PASS(); } +/* Issue #2537: a Go 1.22 ServeMux pattern may open with a method and/or a host + * ("POST /v1/payments", "GET api.example.com/v1/x"). The written pattern stays + * the first string argument, and its handler is captured exactly as for a + * plain "/v1/payments"; it was dropped because the pattern does not open with + * a slash. */ +TEST(extract_go_servemux_method_pattern_handler_issue2537) { + CBMFileResult *r = extract("package main\n" + "import \"net/http\"\n" + "func setup(mux *http.ServeMux) {\n" + " mux.HandleFunc(\"POST /v1/payments\", createPayment)\n" + " mux.Handle(\"GET api.example.com/v1/x\", hostHandler)\n" + "}\n", + CBM_LANG_GO, "t", "routes.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + const CBMCall *method = find_call_by_callee(r, "mux.HandleFunc"); + ASSERT_NOT_NULL(method); + ASSERT_NOT_NULL(method->first_string_arg); + ASSERT_STR_EQ(method->first_string_arg, "POST /v1/payments"); + ASSERT_NOT_NULL(method->second_arg_name); + ASSERT_STR_EQ(method->second_arg_name, "createPayment"); + + const CBMCall *host = find_call_by_callee(r, "mux.Handle"); + ASSERT_NOT_NULL(host); + ASSERT_NOT_NULL(host->first_string_arg); + ASSERT_STR_EQ(host->first_string_arg, "GET api.example.com/v1/x"); + ASSERT_NOT_NULL(host->second_arg_name); + ASSERT_STR_EQ(host->second_arg_name, "hostHandler"); + + cbm_free_result(r); + PASS(); +} + /* Issue #2291: the JS/TS twin of #1249. A BFF that builds its backend URL as * `${process.env.BACKEND_URL}/wallet/topup` from a configurable base must * index the literal path after the base, exactly as `BACKEND_URL + "/x"` @@ -13442,6 +13476,7 @@ SUITE(extraction) { RUN_TEST(extract_ts_config_object_url_issue2235); RUN_TEST(extract_go_binary_concat_url_issue1249); RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249); + RUN_TEST(extract_go_servemux_method_pattern_handler_issue2537); RUN_TEST(extract_ts_template_base_url_issue2291); RUN_TEST(extract_ts_template_base_url_no_literal_path_issue2291); RUN_TEST(extract_py_concat_const_url_issue706); diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index abfa8788c0..2e33e51683 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -549,12 +549,46 @@ TEST(infrascan_data_flows_test_handlers_pair_only_within_their_file) { PASS(); } +/* Issue #2537: the Go 1.22 ServeMux grammar "[METHOD ][HOST]/[PATH]". The path + * is found inside the written pattern; a plain path, a lowercase word, a + * pattern without a path and a method too long for the buffer are no split. */ +TEST(infrascan_servemux_pattern_split_issue2537) { + char m[16]; + const char *pat = "POST /v1/payments"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 5); + ASSERT_STR_EQ(m, "POST"); + pat = "GET \t /v1/payments/{id}"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 6); + ASSERT_STR_EQ(m, "GET"); + pat = "GET api.example.com/v1/x"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 19); + ASSERT_STR_EQ(m, "GET"); + pat = "api.example.com:8443/v1/x"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 20); + ASSERT_STR_EQ(m, ""); + pat = "DELETE /"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, NULL, 0) == pat + 7); + + ASSERT_NULL(cbm_service_pattern_mux_split("/v1/x", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("get /v1/x", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("POST", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("POST ", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("GET host", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("GET a b/x", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split(NULL, m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("ABCDEFGHIJKLMNOP /x", m, sizeof(m))); + ASSERT_STR_EQ(m, ""); + PASS(); +} + SUITE(infrascan) { RUN_TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths); RUN_TEST(infrascan_http_route_literal_guard_rejects_document_extensions); RUN_TEST(infrascan_client_navigation_is_js_family_only); RUN_TEST(infrascan_service_pattern_match_rejects_ids_inside_words); RUN_TEST(infrascan_service_pattern_match_keeps_real_library_qns); + RUN_TEST(infrascan_servemux_pattern_split_issue2537); RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths); RUN_TEST(infrascan_http_calls_join_matching_handler_route); RUN_TEST(infrascan_data_flows_link_every_caller_of_a_route); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index bd76775f30..e30d95a26a 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -13376,6 +13376,130 @@ TEST(pipeline_route_prefix_bridge_is_order_free_and_root_bridges_nothing) { PASS(); } +/* Issue #2537: since Go 1.22 a ServeMux pattern may name a method and a host + * before the path ("POST /v1/payments", "GET api.example.com/v1/x"). Such a + * registration minted no Route and no HANDLES edge, because every registration + * gate wanted the first argument to open with a slash, while the same file + * written with "/v1/payments" had both. The method now comes from the pattern + * and a host is dropped from the path, in the sequential and the parallel pass + * alike. Only Go reads that grammar: a Python registration of "GET /py" is still + * no route. */ +TEST(pipeline_go_servemux_method_patterns_issue2537) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_servemux_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "go.mod", "module example.com/srv\n\ngo 1.22\n"); + write_temp_file(tmp, "main.go", + "package main\n" + "\n" + "import \"net/http\"\n" + "\n" + "func createPayment(w http.ResponseWriter, r *http.Request) {}\n" + "func getPayment(w http.ResponseWriter, r *http.Request) {}\n" + "func hostOnly(w http.ResponseWriter, r *http.Request) {}\n" + "func legacyList(w http.ResponseWriter, r *http.Request) {}\n" + "\n" + "func main() {\n" + "\tmux := http.NewServeMux()\n" + "\tmux.HandleFunc(\"POST /v1/payments\", createPayment)\n" + "\tmux.HandleFunc(\"GET /v1/payments/{id}\", getPayment)\n" + "\tmux.HandleFunc(\"api.example.com/v1/hosted\", hostOnly)\n" + "\tmux.HandleFunc(\"/v1/legacy\", legacyList)\n" + "\t_ = http.ListenAndServe(\":8080\", mux)\n" + "}\n"); + write_temp_file(tmp, "web/site.py", + "from flask import Flask\n" + "app = Flask(__name__)\n" + "\n" + "def py_view():\n" + " return \"ok\"\n" + "\n" + "app.add_url_rule(\"GET /py\", \"py_view\", py_view)\n"); + /* past MIN_FILES_FOR_PARALLEL, so the threaded run takes the parallel pass */ + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/padding%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc Padding%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + int run_rc[2] = {-1, -1}; + bool opened[2] = {false, false}; + int post[2] = {-1, -1}; + int get_id[2] = {-1, -1}; + int any_post[2] = {-1, -1}; + int host[2] = {-1, -1}; + int legacy[2] = {-1, -1}; + int py_any[2] = {-1, -1}; + int py_get[2] = {-1, -1}; + for (int mode = 0; mode < 2; mode++) { + if (mode == 0) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/servemux_%d.db", tmp, mode); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + run_rc[mode] = p ? cbm_pipeline_run(p) : -1; + const char *project = p ? cbm_pipeline_project_name(p) : NULL; + cbm_store_t *s = cbm_store_open_path(db_path); + opened[mode] = s != NULL; + if (s && project) { + post[mode] = + handles_to_route(s, project, "createPayment", "__route__POST__/v1/payments"); + get_id[mode] = + handles_to_route(s, project, "getPayment", "__route__GET__/v1/payments/{}"); + any_post[mode] = + handles_to_route(s, project, "createPayment", "__route__ANY__/v1/payments"); + host[mode] = handles_to_route(s, project, "hostOnly", "__route__ANY__/v1/hosted"); + legacy[mode] = handles_to_route(s, project, "legacyList", "__route__ANY__/v1/legacy"); + py_any[mode] = handles_to_route(s, project, "py_view", "__route__ANY__/py"); + py_get[mode] = handles_to_route(s, project, "py_view", "__route__GET__/py"); + } + if (s) { + cbm_store_close(s); + } + cbm_pipeline_free(p); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + for (int mode = 0; mode < 2; mode++) { + ASSERT_EQ(run_rc[mode], 0); + ASSERT_TRUE(opened[mode]); + /* the method is the pattern's, not ANY */ + ASSERT_EQ(post[mode], 1); + ASSERT_EQ(get_id[mode], 1); + ASSERT_EQ(any_post[mode], 0); + /* a host names no method and is not part of the path */ + ASSERT_EQ(host[mode], 1); + /* control: a plain path is unchanged */ + ASSERT_EQ(legacy[mode], 1); + /* another language's "GET /py" is not read as a ServeMux pattern */ + ASSERT_EQ(py_any[mode], 0); + ASSERT_EQ(py_get[mode], 0); + } + PASS(); +} + TEST(pipeline_swift_http_call_makes_route_issue1892) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX"); @@ -27725,6 +27849,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892); RUN_TEST(pipeline_swift_http_call_makes_route_issue1892); RUN_TEST(pipeline_route_prefix_bridge_is_order_free_and_root_bridges_nothing); + RUN_TEST(pipeline_go_servemux_method_patterns_issue2537); RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls); RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin); From 1d7ce9ba6ac0294d7e1a2ef3a3ae3c9c0259b996 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 22:09:31 +0200 Subject: [PATCH 40/97] 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 5b9e283b61..66f6f3b25f 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 dc198bb654..1a73b34274 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 2943fd746a..96611bf8dd 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 91697b961d..49b3efd1f1 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 1c75446313..9a364e9414 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 ee6e78b7142d51f849acaaa34cf010492568a28c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 23:22:14 +0200 Subject: [PATCH 41/97] fix(java): ignore comment extras in record accessor parameters Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 14 +++++- tests/test_call_reference_contract.c | 70 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b88d779289..2525f0a151 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9590,7 +9590,19 @@ static bool record_declares_accessor(CBMExtractCtx *ctx, TSNode node, const char } /* Same name, no parameters — that is the accessor. */ TSNode params = ts_node_child_by_field_name(member, TS_FIELD("parameters")); - if (ts_node_is_null(params) || ts_node_named_child_count(params) == 0) { + if (ts_node_is_null(params)) { + return true; + } + /* Comments are named extras, not parameters. */ + bool has_parameters = false; + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + if (!ts_node_is_extra(ts_node_named_child(params, pi))) { + has_parameters = true; + break; + } + } + if (!has_parameters) { return true; } } diff --git a/tests/test_call_reference_contract.c b/tests/test_call_reference_contract.c index 67e30132aa..ec5ce4abe5 100644 --- a/tests/test_call_reference_contract.c +++ b/tests/test_call_reference_contract.c @@ -578,6 +578,74 @@ TEST(call_java_record_varargs_accessor_has_array_return) { PASS(); } +/* Comment extras in an otherwise empty parameter list must not create a + * second, implicit accessor at the record component's source location. */ +TEST(call_java_record_commented_accessor_has_only_explicit_span) { + static const char *sources[] = {"record Value(int x) {\n" + " public int x(/* explanation */) {\n" + " return x;\n" + " }\n" + "}\n", + "record Value(int x) {\n" + " public int x(// explanation\n" + " ) {\n" + " return x;\n" + " }\n" + "}\n"}; + static const int end_lines[] = {4, 5}; + for (int fixture = 0; fixture < 2; fixture++) { + CBMFileResult *result = + cbm_extract_file(sources[fixture], (int)strlen(sources[fixture]), CBM_LANG_JAVA, + "repro", "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int accessors = 0; + int explicit_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + accessors++; + if (def->start_line == 2 && def->end_line == end_lines[fixture]) { + explicit_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(accessors, 1); + ASSERT_EQ(explicit_spans, 1); + } + PASS(); +} + +TEST(call_java_record_commented_overload_keeps_implicit_accessor) { + static const char source[] = + "record Value(int x) {\n" + " public int x(/* explanation */ int scale) { return x * scale; }\n" + "}\n"; + CBMFileResult *result = cbm_extract_file(source, (int)strlen(source), CBM_LANG_JAVA, "repro", + "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int methods = 0; + int implicit_spans = 0; + int overload_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + methods++; + if (def->start_line == 1 && def->end_line == 1) { + implicit_spans++; + } + if (def->start_line == 2 && def->end_line == 2) { + overload_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(methods, 2); + ASSERT_EQ(implicit_spans, 1); + ASSERT_EQ(overload_spans, 1); + PASS(); +} + SUITE(call_reference_contract) { RUN_TEST(call_reference_typescript_direct_argument_is_exact); RUN_TEST(call_reference_kotlin_alias_argument_is_exact); @@ -596,6 +664,8 @@ SUITE(call_reference_contract) { RUN_TEST(call_reference_go_ambiguous_promoted_method_stays_usage); RUN_TEST(call_java_record_accessor_binds_the_record); RUN_TEST(call_java_record_varargs_accessor_has_array_return); + RUN_TEST(call_java_record_commented_accessor_has_only_explicit_span); + RUN_TEST(call_java_record_commented_overload_keeps_implicit_accessor); RUN_TEST(call_java_static_import_does_not_call_field_sequential); RUN_TEST(call_java_static_import_does_not_call_field_parallel); } 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 42/97] 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 7cdac83bf1..1beb10435c 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 d3c3ef8bd2..4ab89d3201 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 1ebe1687e8..760b4319f0 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 00c6b4e685..76b30deb24 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 5bd02e4e38..35ab94fa82 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 0d6ef30105..7be8f6950c 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 698d9dc7a6..a19ca549fd 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; - } -#endif - cbm_store_t *adr_store = cbm_store_open_path_query(db_path); - if (!adr_store) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - 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; +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; } - if (adr_rc != CBM_STORE_OK || !existing.content) { - cbm_store_adr_free(&existing); - cbm_store_close(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); } - 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 ce1c89f6f6..a327046dfe 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 27c37098dd..52489f1406 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 d19e528908..6061ea657d 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 0f291daf9c..f498583eed 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 8bbec658d5..bb7990c9ed 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 80a320ca59..e6f383bb7e 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 75e5cb43d3..07d8a79e92 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 8ed3f46364..36dad3ef02 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 a9434d88e9014e2770a13c57aa558c4924aeb700 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:44:06 +0000 Subject: [PATCH 43/97] build(deps): Bump github/codeql-action/upload-sarif Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.38.1 to 4.39.1. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/1c5b675653bb5c22dbe9b12b556ec555138e09fd...9b3254085494b55a0f7c4c2b3d2d52cc712ce370) --- updated-dependencies: - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fb0a52bbc7..dfb8ae59d2 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -33,6 +33,6 @@ jobs: publish_results: true - name: Upload SARIF results - uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: sarif_file: results.sarif From db74648c22b9f057a16ae25814d5e762c415ab32 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 25 Oct 2026 21:03:17 +0100 Subject: [PATCH 44/97] 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 | 37 ++++++++++++++++++++++++++++++++----- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 5474e8f5e5..99bf23f6e3 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 a9ac979357..e7222e4ddb 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 2d6ba3ea38..e20de20348 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 96825bccad..e9c636d83b 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 4d97d7819b80f96e2492569ae54b442af4406f98 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 26 Oct 2026 01:46:33 +0100 Subject: [PATCH 45/97] fix(routes): read a ServeMux pattern only for Handle and HandleFunc The Go 1.22 pattern split ran for every route-verb callee, so other Go calls whose name ends in Get/Post/Put/Delete/Patch became route registrations: st.Get("users/123") on a project method minted __route__GET__/123 and lost its CALLS edge to Store.Get, and c.Get("https://api.example.com/v1/x") split "https:" off as a host and minted __route__GET__//api.example.com/v1/x, in both passes. cbm_service_pattern_mux_route_path reads the pattern only for a callee ending ".Handle" or ".HandleFunc", and never for an argument containing "://". The extractor's handler capture and both passes (route_reg_path, find_route_path_in_args, cbm_pipeline_route_reg_method) go through it, so the two passes still mint identical Routes. Two negative cases extend pipeline_go_servemux_method_patterns_issue2537 in both passes, and the infrascan unit covers the gate. Refs #2537 Signed-off-by: Martin Vogel --- internal/cbm/extract_calls.c | 3 +- internal/cbm/service_patterns.c | 15 ++++++ internal/cbm/service_patterns.h | 8 ++++ src/pipeline/pass_calls.c | 7 +-- src/pipeline/pass_parallel.c | 13 +++--- src/pipeline/pipeline_internal.h | 11 +++-- tests/test_infrascan.c | 17 ++++++- tests/test_pipeline.c | 80 +++++++++++++++++++++++++++++++- 8 files changed, 137 insertions(+), 17 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index f16b8ecf88..4beebc220c 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -4559,7 +4559,8 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML if (call.first_string_arg && (call.first_string_arg[0] == '/' || (ctx->language == CBM_LANG_GO && - cbm_service_pattern_mux_split(call.first_string_arg, NULL, 0) != NULL)) && + cbm_service_pattern_mux_route_path(call.callee_name, call.first_string_arg) != + NULL)) && !(laravel_uri && php_route_action_is_string(node))) { call.second_arg_name = extract_handler_arg(ctx, args); } diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 1eaa509414..503d185389 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -1227,6 +1227,21 @@ const char *cbm_service_pattern_mux_split(const char *pattern, char *method, siz return slash; } +const char *cbm_service_pattern_mux_route_path(const char *callee_name, const char *pattern) { + static const char *const mux_callees[] = {".Handle", ".HandleFunc"}; + if (!callee_name || !pattern || strstr(pattern, "://") != NULL) { + return NULL; + } + size_t clen = strlen(callee_name); + for (size_t i = 0; i < sizeof(mux_callees) / sizeof(mux_callees[0]); i++) { + size_t slen = strlen(mux_callees[i]); + if (clen >= slen && strcmp(callee_name + clen - slen, mux_callees[i]) == 0) { + return cbm_service_pattern_mux_split(pattern, NULL, 0); + } + } + return NULL; +} + const char *cbm_service_pattern_broker(const char *resolved_qn) { if (!resolved_qn) { return NULL; diff --git a/internal/cbm/service_patterns.h b/internal/cbm/service_patterns.h index 59f3ea0afa..0a182568e0 100644 --- a/internal/cbm/service_patterns.h +++ b/internal/cbm/service_patterns.h @@ -95,6 +95,14 @@ const char *cbm_service_pattern_route_method(const char *callee_name); * on the file language. */ const char *cbm_service_pattern_mux_split(const char *pattern, char *method, size_t method_sz); +/* The path of a ServeMux registration's pattern (#2537): only a callee ending + * ".Handle" or ".HandleFunc" registers one, and never with a URL ("://"). + * Another route-verb call names no pattern: `store.Get("users/123")` is no + * host "users" + path "/123", `client.Get("https://...")` no host "https:". + * The split of cbm_service_pattern_mux_split for such a registration, else + * NULL. The grammar is Go's: callers gate on the file language. */ +const char *cbm_service_pattern_mux_route_path(const char *callee_name, const char *pattern); + /* Get the broker name for an async QN (e.g., "pubsub" from a Pub/Sub QN). * Returns NULL if not an async pattern. */ const char *cbm_service_pattern_broker(const char *resolved_qn); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 0e7069ab67..97c8e784e1 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -573,8 +573,9 @@ cbm_svc_kind_t cbm_pipeline_external_import_service(cbm_external_import_index_t /* The path a route registration's first argument names: the argument itself * when it opens with a slash, or in Go the path of a ServeMux pattern that - * names a method or host first ("POST /v1/x", #2537). NULL when it names no - * path. MUST match pass_parallel.c find_route_path_in_args. */ + * names a method or host first ("POST /v1/x", #2537; Handle / HandleFunc + * only). NULL when it names no path. MUST match pass_parallel.c + * find_route_path_in_args. */ static const char *route_reg_path(const CBMCall *call, CBMLanguage lang) { const char *arg = call->first_string_arg; if (!arg) { @@ -583,7 +584,7 @@ static const char *route_reg_path(const CBMCall *call, CBMLanguage lang) { if (arg[0] == '/') { return arg; } - return cbm_pipeline_mux_route_path(arg, lang); + return cbm_pipeline_mux_route_path(call, lang); } /* Handle a route registration call: create Route node + HANDLES edge. diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 3801102955..feff58fdb3 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2263,11 +2263,12 @@ static bool is_route_path_shaped(const char *val) { return val && val[0] == '/' && !cbm_service_pattern_is_comment_text(val); } -const char *cbm_pipeline_mux_route_path(const char *arg, CBMLanguage lang) { +const char *cbm_pipeline_mux_route_path(const CBMCall *call, CBMLanguage lang) { + const char *arg = call->first_string_arg; if (lang != CBM_LANG_GO || !arg || cbm_service_pattern_is_comment_text(arg)) { return NULL; } - return cbm_service_pattern_mux_split(arg, NULL, 0); + return cbm_service_pattern_mux_route_path(call->callee_name, arg); } const char *cbm_pipeline_route_reg_method(const CBMCall *call, CBMLanguage lang, char *buf, @@ -2276,7 +2277,7 @@ const char *cbm_pipeline_route_reg_method(const CBMCall *call, CBMLanguage lang, if (method && strcmp(method, "ANY") != 0) { return method; } - if (cbm_pipeline_mux_route_path(call->first_string_arg, lang) && + if (cbm_pipeline_mux_route_path(call, lang) && cbm_service_pattern_mux_split(call->first_string_arg, buf, buf_sz) && buf[0] != '\0') { return buf; } @@ -2291,9 +2292,9 @@ static const char *find_route_path_in_args(const CBMCall *call, const char **out *out_handler = call->second_arg_name; return call->first_string_arg; } - /* 1b. Go ServeMux pattern with a method or host before the path (#2537). - * MUST match pass_calls.c route_reg_path. */ - const char *mux_path = cbm_pipeline_mux_route_path(call->first_string_arg, lang); + /* 1b. Go ServeMux pattern with a method or host before the path (#2537), + * read for Handle / HandleFunc only. MUST match pass_calls.c route_reg_path. */ + const char *mux_path = cbm_pipeline_mux_route_path(call, lang); if (mux_path) { *out_handler = call->second_arg_name; return mux_path; diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index d6882067ed..d2fb1c7365 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -788,11 +788,12 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje void cbm_pipeline_close_call_props(char *props, int n, const CBMCall *call, bool with_line); /* Go 1.22 ServeMux registrations may name a method and a host before the path - * ("POST /v1/x", "GET api.example.com/v1/x", #2537). For a Go file, the path - * inside such a route-registration argument; NULL for any other language and - * for an argument that is no such pattern (a plain "/v1/x" included). The one - * reader of that grammar behind both passes (pass_parallel.c). */ -const char *cbm_pipeline_mux_route_path(const char *arg, CBMLanguage lang); + * ("POST /v1/x", "GET api.example.com/v1/x", #2537). For a Go `Handle` / + * `HandleFunc` call, the path inside its first argument; NULL for any other + * language or callee (`store.Get("users/123")`), for a URL argument, and for an + * argument that is no such pattern (a plain "/v1/x" included). The one reader + * of that grammar behind both passes (pass_parallel.c). */ +const char *cbm_pipeline_mux_route_path(const CBMCall *call, CBMLanguage lang); /* The method of a route registration's Route: the verb its callee names * (`router.GET`), else the method its Go ServeMux pattern names diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index 2e33e51683..94ba522e1e 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -551,7 +551,8 @@ TEST(infrascan_data_flows_test_handlers_pair_only_within_their_file) { /* Issue #2537: the Go 1.22 ServeMux grammar "[METHOD ][HOST]/[PATH]". The path * is found inside the written pattern; a plain path, a lowercase word, a - * pattern without a path and a method too long for the buffer are no split. */ + * pattern without a path and a method too long for the buffer are no split. + * Only a Handle / HandleFunc registration is read with it. */ TEST(infrascan_servemux_pattern_split_issue2537) { char m[16]; const char *pat = "POST /v1/payments"; @@ -579,6 +580,20 @@ TEST(infrascan_servemux_pattern_split_issue2537) { ASSERT_NULL(cbm_service_pattern_mux_split(NULL, m, sizeof(m))); ASSERT_NULL(cbm_service_pattern_mux_split("ABCDEFGHIJKLMNOP /x", m, sizeof(m))); ASSERT_STR_EQ(m, ""); + + /* only Handle / HandleFunc register a pattern, and never with a URL */ + pat = "POST /v1/payments"; + ASSERT_TRUE(cbm_service_pattern_mux_route_path("mux.HandleFunc", pat) == pat + 5); + pat = "api.example.com/v1/x"; + ASSERT_TRUE(cbm_service_pattern_mux_route_path("s.mux.Handle", pat) == pat + 15); + ASSERT_NULL(cbm_service_pattern_mux_route_path("st.Get", "users/123")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("c.Get", "https://api.example.com/v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("mux.Handle", "https://api.example.com/v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("PatchCollector.Delete", "storage.k8s.io/v1")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("r.Route", "GET /v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("mux.HandleFunc", "/v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path(NULL, "GET /v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("mux.HandleFunc", NULL)); PASS(); } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index e30d95a26a..e1e5e0f5db 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -13376,6 +13376,53 @@ TEST(pipeline_route_prefix_bridge_is_order_free_and_root_bridges_nothing) { PASS(); } +/* Route nodes whose qualified name contains `part`. */ +static int route_nodes_containing(cbm_store_t *s, const char *project, const char *part) { + cbm_node_t *routes = NULL; + int route_count = 0; + if (cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count) != CBM_STORE_OK) { + return -1; + } + int matches = 0; + for (int i = 0; i < route_count; i++) { + if (routes[i].qualified_name && strstr(routes[i].qualified_name, part)) { + matches++; + } + } + if (routes) { + cbm_store_free_nodes(routes, route_count); + } + return matches; +} + +/* CALLS edges out of the function `source_name` into a Route node: the edge a + * route registration emits beside its Route (an HTTP client call's edge to a + * Route is HTTP_CALLS instead). */ +static int calls_into_route_count(cbm_store_t *s, const char *project, const char *source_name) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count) != CBM_STORE_OK) { + return -1; + } + int matches = 0; + for (int i = 0; i < edge_count; i++) { + cbm_node_t source = {0}; + cbm_node_t target = {0}; + int source_ok = cbm_store_find_node_by_id(s, edges[i].source_id, &source) == CBM_STORE_OK; + int target_ok = cbm_store_find_node_by_id(s, edges[i].target_id, &target) == CBM_STORE_OK; + if (source_ok && target_ok && source.name && target.label && + strcmp(source.name, source_name) == 0 && strcmp(target.label, "Route") == 0) { + matches++; + } + cbm_node_free_fields(&source); + cbm_node_free_fields(&target); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + return matches; +} + /* Issue #2537: since Go 1.22 a ServeMux pattern may name a method and a host * before the path ("POST /v1/payments", "GET api.example.com/v1/x"). Such a * registration minted no Route and no HANDLES edge, because every registration @@ -13383,7 +13430,10 @@ TEST(pipeline_route_prefix_bridge_is_order_free_and_root_bridges_nothing) { * written with "/v1/payments" had both. The method now comes from the pattern * and a host is dropped from the path, in the sequential and the parallel pass * alike. Only Go reads that grammar: a Python registration of "GET /py" is still - * no route. */ + * no route. Only a ServeMux registration (Handle / HandleFunc) is read with it: + * another route-verb call names no pattern and mints no Route, neither + * `st.Get("users/123")` on a project method (which keeps its CALLS edge) nor + * `c.Get("https://api.example.com/v1/x")` on a client. */ TEST(pipeline_go_servemux_method_patterns_issue2537) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_servemux_XXXXXX"); @@ -13401,6 +13451,16 @@ TEST(pipeline_go_servemux_method_patterns_issue2537) { "func hostOnly(w http.ResponseWriter, r *http.Request) {}\n" "func legacyList(w http.ResponseWriter, r *http.Request) {}\n" "\n" + "type Store struct{ m map[string]string }\n" + "\n" + "func (s *Store) Get(k string) string { return s.m[k] }\n" + "\n" + "func lookup(st *Store) string { return st.Get(\"users/123\") }\n" + "\n" + "func fetchRemote(c *http.Client) {\n" + "\t_, _ = c.Get(\"https://api.example.com/v1/x\")\n" + "}\n" + "\n" "func main() {\n" "\tmux := http.NewServeMux()\n" "\tmux.HandleFunc(\"POST /v1/payments\", createPayment)\n" @@ -13438,6 +13498,11 @@ TEST(pipeline_go_servemux_method_patterns_issue2537) { int legacy[2] = {-1, -1}; int py_any[2] = {-1, -1}; int py_get[2] = {-1, -1}; + int store_calls[2] = {-1, -1}; + int store_route[2] = {-1, -1}; + int lookup_reg[2] = {-1, -1}; + int remote_route[2] = {-1, -1}; + int remote_reg[2] = {-1, -1}; for (int mode = 0; mode < 2; mode++) { if (mode == 0) { cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); @@ -13463,6 +13528,11 @@ TEST(pipeline_go_servemux_method_patterns_issue2537) { legacy[mode] = handles_to_route(s, project, "legacyList", "__route__ANY__/v1/legacy"); py_any[mode] = handles_to_route(s, project, "py_view", "__route__ANY__/py"); py_get[mode] = handles_to_route(s, project, "py_view", "__route__GET__/py"); + store_calls[mode] = named_edge_count(s, project, "CALLS", "lookup", "Get"); + store_route[mode] = route_nodes_containing(s, project, "__route__GET__/123"); + lookup_reg[mode] = calls_into_route_count(s, project, "lookup"); + remote_route[mode] = route_nodes_containing(s, project, "//api.example.com"); + remote_reg[mode] = calls_into_route_count(s, project, "fetchRemote"); } if (s) { cbm_store_close(s); @@ -13496,6 +13566,14 @@ TEST(pipeline_go_servemux_method_patterns_issue2537) { /* another language's "GET /py" is not read as a ServeMux pattern */ ASSERT_EQ(py_any[mode], 0); ASSERT_EQ(py_get[mode], 0); + /* a project method named Get keeps its CALLS edge; "users/123" is no + * host + path */ + ASSERT_EQ(store_calls[mode], 1); + ASSERT_EQ(store_route[mode], 0); + ASSERT_EQ(lookup_reg[mode], 0); + /* a client Get on a URL registers no route: "https:" is no host */ + ASSERT_EQ(remote_route[mode], 0); + ASSERT_EQ(remote_reg[mode], 0); } PASS(); } From 158a0c9da2919f5ad43d03d99528c9cd5a3d2044 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 26 Oct 2026 02:22:58 +0100 Subject: [PATCH 46/97] 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 d13002cd97..6381e58d9f 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 729ce339bd130e44d317d7ecf5c296bdfe016350 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 26 Oct 2026 02:56:12 +0100 Subject: [PATCH 47/97] fix(java): register generated record accessors by qualified name only A record's implicit accessors are members the compiler writes and the source never spells. Their nodes now carry the property generated=record, and every registry registration path files them in the exact (qualified-name) map only, never in the short-name index. The Java type-aware resolver still binds p.y() on a Point to Point.y; a call such as v.y() on a type from outside the project no longer binds the accessor through a unique_name / suffix_match / field_type_hint / fuzzy guess. - extract_defs.c: each implicit accessor carries extra_props generated=record (key CBM_PROP_GENERATED, cbm.h). - registry.c: cbm_registry_add_qn_only, and the pure predicates cbm_def_props_generated / cbm_node_props_generated. - pass_definitions.c, pass_parallel.c, pipeline_incremental.c: the three registration sites route a generated member to the exact map only. - pipeline_delta.c: the incremental preseed proxy keeps the mark, so an incremental re-index registers the member exactly as a full one does (the proxy carried only a few properties and the accessor came back into the short-name index). Tests: pipeline_java_record_accessors_generated_qn_only (sequential, parallel and incremental venues) and two registry unit tests. Signed-off-by: Martin Vogel --- internal/cbm/cbm.h | 14 +- internal/cbm/extract_defs.c | 10 ++ src/pipeline/pass_definitions.c | 11 +- src/pipeline/pass_parallel.c | 11 +- src/pipeline/pipeline.h | 18 +++ src/pipeline/pipeline_delta.c | 54 ++++++- src/pipeline/pipeline_incremental.c | 6 + src/pipeline/registry.c | 26 +++ tests/test_pipeline.c | 236 ++++++++++++++++++++++++++++ tests/test_registry.c | 47 ++++++ 10 files changed, 419 insertions(+), 14 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index ade41fea57..ef5fad51f1 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -298,9 +298,10 @@ typedef struct { * are bound to the owning result's source identity before cross-file use. */ uint32_t test_name_start_byte, test_name_end_byte; uint32_t test_body_start_byte, test_body_end_byte; - /* Documents only: facts a document definition adds to its node's - * properties, as a NULL-terminated key, value, key, value, ... list (an - * ADR's id, status, date, deciders, title). Keys are fixed literals; the + /* Facts a definition adds to its node's properties, as a NULL-terminated + * key, value, key, value, ... list: a document's (an ADR's id, status, + * date, deciders, title), or CBM_PROP_GENERATED on a member the compiler + * writes and the source never spells. Keys are fixed literals; the * values are escaped where the properties are written. Tail field: * zero-init stays valid. */ const char **extra_props; @@ -315,6 +316,13 @@ typedef struct { const char *variant_key; } CBMDefinition; +/* extra_props key of a generated member, one the compiler writes and the + * source never spells: a Java record's implicit accessor carries + * `generated: record`. The node keeps the property, and the pipeline registers + * such a definition by qualified name only, so no short-name guess can land on + * it (pipeline.h cbm_def_props_generated). */ +#define CBM_PROP_GENERATED "generated" + /* Argument captured from a call expression */ typedef struct { const char *expr; // raw expression text ("payload.info", "MY_URL", "'hello'") diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2525f0a151..65829e9b80 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9668,6 +9668,16 @@ static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char const char *type = cbm_node_text(a, ctype, ctx->source); adef.return_type = spread && type ? cbm_arena_sprintf(a, "%s[]", type) : type; } + /* The compiler writes this member and the source never spells it: its + * node says so, and the pipeline registers it by qualified name only, + * so no short-name guess can land on it. */ + const char **kv = (const char **)cbm_arena_alloc(a, CBM_SZ_3 * sizeof(char *)); + if (kv) { + kv[0] = CBM_PROP_GENERATED; + kv[SKIP_ONE] = "record"; + kv[PAIR_LEN] = NULL; + } + adef.extra_props = kv; cbm_defs_push(&ctx->result->defs, a, adef); } } diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index ed60d3f9ac..45bb5d3a7d 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -509,10 +509,15 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (helpers.c): callables + type-like containers (INHERITS/IMPLEMENTS/method/ * field resolution), Variable/Field (READS/WRITES resolution), and Table/View * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed - * through the same predicate, so the three registries cannot diverge. */ + * through the same predicate, so the three registries cannot diverge. + * A generated member is registered by qualified name only, at all three. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, - lang, module_prefix_len); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, + def->label, lang, module_prefix_len); + } } char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 23f4934cea..dd04e3ad9d 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1811,10 +1811,15 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d return 0; } /* Registry membership is defined ONCE by cbm_label_is_registry_symbol - * (helpers.c) — see pass_definitions.c for the per-label rationale. */ + * (helpers.c) — see pass_definitions.c for the per-label rationale, and + * for the generated member registered by qualified name only. */ if (cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, - lang, module_prefix_len); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, + def->label, lang, module_prefix_len); + } (*reg_entries)++; } char marked[CBM_SZ_4K]; diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index fca3100042..b564b45750 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -277,6 +277,24 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label, CBMLanguage lang, size_t module_prefix_len); +/* Register a symbol for exact (qualified-name) lookups only: it is never + * offered to the short-name strategies (unique_name, suffix_match, + * field_type_hint, fuzzy). For a generated member (cbm_def_props_generated), + * which the source never spells, so only a type-aware resolver that knows its + * owner may bind it. The three graph registration sites route such a symbol + * here instead of cbm_registry_add_lang_in_module. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label); + +/* True when a definition's extra_props (key, value, ..., NULL) carry the + * generated-member key (cbm.h CBM_PROP_GENERATED), e.g. a Java record's + * implicit accessor. Pure; unit-tested in test_registry.c. */ +bool cbm_def_props_generated(const char *const *extra_props); + +/* The stored-node twin of cbm_def_props_generated, for the incremental + * registry seed: true when a node's properties JSON carries that key as a + * string property. Pure; unit-tested in test_registry.c. */ +bool cbm_node_props_generated(const char *properties_json); + /* True when code in `caller` can call a symbol defined in `target` by bare * name: same language, same interop family (JS/TS + script hosts, C/C++/CUDA/ * ObjC, JVM, .NET, ...; the table lives in registry.c), or Swift with the C diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index 895cded935..ca1dbab469 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -590,6 +590,39 @@ static char *delta_owner_props(const char *project, const char *name, const char return out; } +/* `props` (a JSON object) with a generated member's mark (cbm.h + * CBM_PROP_GENERATED) added, or NULL when `generated` is NULL/empty + * (CBM_MEM_CLASS_OTHER; the caller frees). */ +static char *delta_generated_props(const char *props, const char *generated) { + if (!generated || !generated[0]) { + return NULL; + } + size_t pl = props ? strlen(props) : 0; + bool has_members = pl > PAIR_LEN && props[0] == '{' && props[pl - SKIP_ONE] == '}'; + size_t cap = pl + PAIR_LEN * strlen(generated) + CBM_SZ_32; + char *out = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, cap); + if (!out) { + return NULL; + } + size_t k = 0; + if (has_members) { + memcpy(out, props, pl - SKIP_ONE); + k = pl - SKIP_ONE; + out[k++] = ','; + } else { + out[k++] = '{'; + } + k += (size_t)snprintf(out + k, cap - k, "\"%s\":\"", CBM_PROP_GENERATED); + for (const char *p = generated; *p && k + CBM_SZ_3 < cap; p++) { + if (*p == '"' || *p == '\\') { + out[k++] = '\\'; + } + out[k++] = *p; + } + snprintf(out + k, cap - k, "\"}"); + return out; +} + int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *gbuf) { sqlite3 *db = cbm_store_get_db(store); if (!db) { @@ -634,7 +667,12 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g * goes by -- a Go method's QN has no receiver, a class-body variable's QN * no class. A document naming `Order.Reset` binds such a node through * its owner (doc_links_pdf.c), so a re-resolved document needs it too. - * Only where owner + name differs from the QN (delta_owner_props). */ + * Only where owner + name differs from the QN (delta_owner_props). + * + * And a generated member's mark (a Java record's implicit accessor, a + * Method node): the registry seed reads it to register the member by + * qualified name only, as the full build does (cbm_node_props_generated); + * without it the proxy came back into the short-name index. */ sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2( db, @@ -646,7 +684,10 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g " start_line, end_line," " CASE WHEN label IN ('Method','Variable')" " AND instr(properties, '\"parent_class\"') > 0" - " THEN json_extract(properties, '$.parent_class') END" + " THEN json_extract(properties, '$.parent_class') END," + " CASE WHEN label = 'Method'" + " AND instr(properties, '\"" CBM_PROP_GENERATED "\"') > 0" + " THEN json_extract(properties, '$." CBM_PROP_GENERATED "') END" " FROM nodes" " WHERE project = ?1 AND label NOT IN" " ('Macro','Comment','Section','Branch','Commit','Tag')" @@ -670,13 +711,16 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g int start_line = sqlite3_column_int(stmt, 6); int end_line = sqlite3_column_int(stmt, 7); const char *parent = (const char *)sqlite3_column_text(stmt, 8); + const char *generated = (const char *)sqlite3_column_text(stmt, 9); char *owner_props = parent ? delta_owner_props(project, name, qn, parent, client_props) : NULL; + const char *props = owner_props ? owner_props : (client_props ? client_props : "{}"); + char *generated_props = delta_generated_props(props, generated); /* Pin the gbuf id to the database id: proxies ARE their rows. */ cbm_gbuf_set_next_id(gbuf, id); - int64_t got = - cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, end_line, - owner_props ? owner_props : (client_props ? client_props : "{}")); + int64_t got = cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, + end_line, generated_props ? generated_props : props); + cbm_free(CBM_MEM_CLASS_OTHER, generated_props); cbm_free(CBM_MEM_CLASS_OTHER, owner_props); if (got != id) { /* A QN collision inside the preseed set would silently split diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 6e67405730..3d44cc6960 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1348,6 +1348,12 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { if (!incr_label_is_registry_symbol(node->label)) { return; } + /* A generated member stays out of the short-name index here as in the + * full index (pass_definitions.c); its node carries the mark. */ + if (cbm_node_props_generated(node->properties_json)) { + cbm_registry_add_qn_only(seed->registry, node->qualified_name, node->label); + return; + } CBMLanguage lang = cbm_registry_synthetic_path_language(node->file_path); if (lang == CBM_LANG_COUNT && seed->lang_by_path && node->file_path) { void *v = cbm_ht_get(seed->lang_by_path, node->file_path); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 209691ea21..51beb1eaf6 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1406,6 +1406,32 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, } } +/* A generated member (a Java record's implicit accessor) is a definition the + * source never spells: no call names it except through its owner's type. The + * exact map holds it for the LSP targets and the import/same-module lookups, + * as it holds a Python function's locals; the by-name index does not, so no + * unique_name / suffix_match / field_type_hint / fuzzy guess can land on it. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label) { + if (!r || !qualified_name || !label) { + return; + } + (void)registry_add_exact(r, qualified_name, label); +} + +bool cbm_def_props_generated(const char *const *extra_props) { + for (const char *const *kv = extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) { + if (strcmp(kv[0], CBM_PROP_GENERATED) == 0) { + return true; + } + } + return false; +} + +bool cbm_node_props_generated(const char *properties_json) { + /* The key as the definition passes write it: "generated":"". */ + return properties_json && strstr(properties_json, "\"" CBM_PROP_GENERATED "\":\"") != NULL; +} + /* ── Lookup ──────────────────────────────────────────────────────── */ bool cbm_registry_exists(const cbm_registry_t *r, const char *qn) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index bd76775f30..6dbbdad39d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -17366,6 +17366,239 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { PASS(); } +/* A Java record's implicit accessors are GENERATED members (#2295): Method + * nodes marked `generated: record`, registered by qualified name only, as a + * Python function's locals are. The type-aware resolver binds `p.y()` on a + * Point to Point.y; no short-name guess may land on a member the source never + * spells. `far` calls y() on a type outside the project, so the project-wide + * name registry is its only route to Point.y. `data` calls x(), whose other + * spelling is Spring's private field: neither the accessor (a name guess) nor + * the field (a Java call never names data) is its target. The record's + * hand-written z() and norm() are source members: no `generated` mark. */ +static const char jgen_point_java[] = + "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double z() { return z; }\n" + " public double norm() { return Math.sqrt(x * x + y * y + z * z); }\n" + "}\n"; +static const char jgen_spring_java[] = "package com.example;\n" + "public final class Spring {\n" + " private double x;\n" + " public void put(double v) { this.x = v; }\n" + "}\n"; +static const char jgen_caller_java[] = "package com.example;\n" + "import org.external.Vector;\n" + "public final class Caller {\n" + " public double near(Point p) { return p.y(); }\n" + " public double far(Vector v) { return v.y(); }\n" + " public double data(Vector v) { return v.x(); }\n" + "}\n"; + +/* The id of the one `label` node called `name` in `file_path`; 0 when there is + * none or more than one. `generated` (optional) receives whether its + * properties carry the `generated: record` mark. */ +static int64_t jgen_node(cbm_store_t *s, const char *project, const char *name, const char *label, + const char *file_path, bool *generated) { + cbm_node_t *nodes = NULL; + int count = 0; + int64_t id = 0; + int hits = 0; + bool marked = false; + if (cbm_store_find_nodes_by_name(s, project, name, &nodes, &count) != CBM_STORE_OK) { + return 0; + } + for (int i = 0; i < count; i++) { + if (nodes[i].label && strcmp(nodes[i].label, label) == 0 && nodes[i].file_path && + strcmp(nodes[i].file_path, file_path) == 0) { + hits++; + id = nodes[i].id; + marked = nodes[i].properties_json && + strstr(nodes[i].properties_json, "\"generated\":\"record\"") != NULL; + } + } + cbm_store_free_nodes(nodes, count); + if (generated) { + *generated = marked; + } + return hits == 1 ? id : 0; +} + +/* CALLS edges out of `source_id` into `target_id`, or (target_id 0) into any + * node called `target_name`. */ +static int jgen_calls(cbm_store_t *s, int64_t source_id, int64_t target_id, + const char *target_name) { + cbm_edge_t *edges = NULL; + int ec = 0; + int n = 0; + if (cbm_store_find_edges_by_source_type(s, source_id, "CALLS", &edges, &ec) != CBM_STORE_OK) { + return -1; + } + for (int i = 0; i < ec; i++) { + if (target_id) { + n += edges[i].target_id == target_id; + continue; + } + cbm_node_t t = {0}; + if (cbm_store_find_node_by_id(s, edges[i].target_id, &t) == CBM_STORE_OK && t.name && + strcmp(t.name, target_name) == 0) { + n++; + } + cbm_node_free_fields(&t); + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return n; +} + +/* One fact of jgen_check: reported, counted, and the check goes on, so a run + * shows every fact that does not hold. */ +#define JGEN_EXPECT(cond) \ + do { \ + if (!(cond)) { \ + printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failed++; \ + } \ + } while (0) + +/* Every fact the fixture pins, on one indexed store; the number that fail. */ +static int jgen_check(cbm_store_t *s, const char *project) { + int failed = 0; + bool gen_x = false; + bool gen_y = false; + bool gen_z = true; + bool gen_norm = true; + int64_t acc_x = jgen_node(s, project, "x", "Method", "Point.java", &gen_x); + int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); + int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); + int64_t norm = jgen_node(s, project, "norm", "Method", "Point.java", &gen_norm); + int64_t near = jgen_node(s, project, "near", "Method", "Caller.java", NULL); + int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); + int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); + JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0); + JGEN_EXPECT(near > 0 && far > 0 && data > 0); + /* The compiler's members are marked; the hand-written ones are not. */ + JGEN_EXPECT(gen_x); + JGEN_EXPECT(gen_y); + JGEN_EXPECT(!gen_z); + JGEN_EXPECT(!gen_norm); + /* Bound by qualified name through the receiver's type ... */ + JGEN_EXPECT(jgen_calls(s, near, acc_y, NULL) == 1); + /* ... and never by a short-name guess. */ + JGEN_EXPECT(jgen_calls(s, far, acc_y, NULL) == 0); + JGEN_EXPECT(jgen_calls(s, far, 0, "y") == 0); + /* Neither the accessor nor Spring's field `x`. */ + JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); + return failed; +} +#undef JGEN_EXPECT + +static int jgen_write_fixture(const char *tmp) { + write_temp_file(tmp, "Point.java", jgen_point_java); + write_temp_file(tmp, "Spring.java", jgen_spring_java); + write_temp_file(tmp, "Caller.java", jgen_caller_java); + return 0; +} + +TEST(pipeline_java_record_accessors_generated_qn_only) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen.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); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* The same fixture through pass_parallel.c: >= 50 files and CBM_WORKERS=4. */ +TEST(pipeline_java_record_accessors_generated_qn_only_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "Filler%d.java", i); + snprintf(body, sizeof(body), + "package com.example;\nclass Filler%d { int f() { return %d; } }\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/java_gen_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int run = p ? cbm_pipeline_run(p) : -1; + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + ASSERT_NOT_NULL(p); + ASSERT_EQ(run, 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* An incremental re-index seeds its registry from the stored graph: the + * untouched Point.java's accessors come back from their nodes, and must stay + * out of the short-name index exactly as in the full index while the touched + * Caller.java is re-resolved. */ +TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_incr_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen_incr.db", tmp); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + cbm_pipeline_free(p1); + + char caller_path[512]; + snprintf(caller_path, sizeof(caller_path), "%s/Caller.java", tmp); + ASSERT_EQ(th_append_file(caller_path, "\n// touched: re-resolve Caller.java incrementally\n"), + 0); + + 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_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p2)); + cbm_store_close(s); + cbm_pipeline_free(p2); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + /* As pyn_edge, and the edge's properties also contain `fragment`. */ static bool pyn_edge_with(cbm_store_t *s, const char *project, const char *src_rel, const char *tgt_rel, const char *type, const char *fragment) { @@ -27777,6 +28010,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_import_never_binds_a_local_def_issue1548); RUN_TEST(pipeline_python_nested_defs_parallel_issue1548); RUN_TEST(pipeline_python_nested_def_local_after_incremental_issue1548); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_parallel); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental); RUN_TEST(pipeline_python_local_def_shadows_module_def_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); RUN_TEST(pipeline_python_super_follows_c3_mro); diff --git a/tests/test_registry.c b/tests/test_registry.c index d7e062bfc6..7eb012679d 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1436,6 +1436,51 @@ TEST(java_call_never_binds_data_member) { PASS(); } +TEST(generated_member_registers_by_qn_only) { + /* A generated member (a Java record's implicit accessor) is found by its + * qualified name, never by a short-name guess. The same member registered + * the ordinary way is exactly what the guess binds. */ + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.y", "Method"); + ASSERT_TRUE(cbm_registry_exists(r, "proj.geo.Point.Point.y")); + ASSERT_STR_EQ(cbm_registry_label_of(r, "proj.geo.Point.Point.y"), "Method"); + const char **qns = NULL; + int n = -1; + ASSERT_EQ(cbm_registry_find_by_name(r, "y", &qns, &n), 0); + ASSERT_EQ(n, 0); + cbm_resolution_t res = cbm_registry_resolve(r, "v.y", "proj.app.Caller", NULL, NULL, 0); + ASSERT_NULL(res.qualified_name); + cbm_registry_add(r, "x", "proj.geo.Point.Point.x", "Method"); + res = cbm_registry_resolve(r, "v.x", "proj.app.Caller", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.geo.Point.Point.x"); + /* Degenerate input registers nothing. */ + cbm_registry_add_qn_only(r, NULL, "Method"); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.z", NULL); + cbm_registry_add_qn_only(NULL, "proj.geo.Point.Point.z", "Method"); + ASSERT_EQ(cbm_registry_size(r), 2); + cbm_registry_free(r); + PASS(); +} + +TEST(generated_member_mark_on_defs_and_nodes) { + static const char *const record[] = {"generated", "record", NULL}; + static const char *const later[] = {"source", "openapi", "generated", "lombok", NULL}; + static const char *const doc[] = {"id", "ADR-7", "status", "accepted", NULL}; + static const char *const keyless[] = {"generated", NULL}; + ASSERT_TRUE(cbm_def_props_generated(record)); + ASSERT_TRUE(cbm_def_props_generated(later)); + ASSERT_FALSE(cbm_def_props_generated(doc)); + ASSERT_FALSE(cbm_def_props_generated(keyless)); /* a key without a value is no fact */ + ASSERT_FALSE(cbm_def_props_generated(NULL)); + ASSERT_TRUE( + cbm_node_props_generated("{\"parent_class\":\"p.Point\",\"generated\":\"record\"}")); + ASSERT_FALSE(cbm_node_props_generated("{\"parent_class\":\"p.Point\"}")); + /* The word inside a value is not the key. */ + ASSERT_FALSE(cbm_node_props_generated("{\"docstring\":\"code generated by protoc\"}")); + ASSERT_FALSE(cbm_node_props_generated(NULL)); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -2298,6 +2343,8 @@ SUITE(registry) { RUN_TEST(c_member_access_binds_by_owner_only); RUN_TEST(c_member_rule_covers_c_files_and_every_dot_h); RUN_TEST(java_call_never_binds_data_member); + RUN_TEST(generated_member_registers_by_qn_only); + RUN_TEST(generated_member_mark_on_defs_and_nodes); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); RUN_TEST(python_builtin_member_table_matches_builtin_type_methods); From 6c52e8260cc01e7cb5470c16b106b28815c426de Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 19:12:42 +0100 Subject: [PATCH 48/97] fix(java): no import resolves to a generated record accessor The import resolver's symbol-name fallback tries the trailing segments of an import path that names no project symbol, and a package segment that merely spells a record accessor bound the import to it. On elasticsearch, `import javax.crypto.spec.PBEKeySpec` bound a record's spec() accessor, and `new PBEKeySpec(...)`, annotations and type uses then followed the import onto it: 6,302 IMPORTS edges into accessors, plus CALLS, USAGE, DECORATES and THROWS through the import map. A generated member is registered by qualified name only, and no Java import can name an instance accessor, so the fallback now skips it exactly as it skips a Python def-local (#1548). The record pipeline fixture gains an import whose package segment spells an accessor. Without the change it fails in the sequential, parallel and incremental venues: an IMPORTS edge into Point.y, and make() -> Point.y. Signed-off-by: Martin Vogel --- src/pipeline/pass_pkgmap.c | 9 +++++++++ tests/test_pipeline.c | 29 ++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index ef3ba0f100..128ba5fc32 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2592,6 +2592,15 @@ static const cbm_gbuf_node_t *resolve_import_strategies( if (import_candidate_is_python_local(ctx->gbuf, cand)) { continue; } + /* Nor is a generated member (a Java record's implicit + * accessor, an instance method no import can name). It is + * registered by qualified name only, and a package segment + * that merely spells it (`javax.crypto.spec` -> spec()) + * must not bind the import -- or every use of the + * imported name -- to it. */ + if (cbm_node_props_generated(cand->properties_json)) { + continue; + } if (source_file_qn && cand->qualified_name && strcmp(cand->qualified_name, source_file_qn) == 0) { continue; /* self */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 6dbbdad39d..940169b77a 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -17374,7 +17374,12 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { * name registry is its only route to Point.y. `data` calls x(), whose other * spelling is Spring's private field: neither the accessor (a name guess) nor * the field (a Java call never names data) is its target. The record's - * hand-written z() and norm() are source members: no `generated` mark. */ + * hand-written z() and norm() are source members: no `generated` mark. + * `org.external.y.Vector` is not in the project, and its package segment `y` + * spells the accessor: the import's symbol-name fallback must not bind it + * there (no IMPORTS edge), or `new Vector()` in `make` would follow the import + * onto the accessor (elasticsearch: `import javax.crypto.spec.PBEKeySpec` + * bound a record's `spec()`). */ static const char jgen_point_java[] = "package com.example;\n" "public record Point(double x, double y, double z) {\n" @@ -17387,11 +17392,12 @@ static const char jgen_spring_java[] = "package com.example;\n" " public void put(double v) { this.x = v; }\n" "}\n"; static const char jgen_caller_java[] = "package com.example;\n" - "import org.external.Vector;\n" + "import org.external.y.Vector;\n" "public final class Caller {\n" " public double near(Point p) { return p.y(); }\n" " public double far(Vector v) { return v.y(); }\n" " public double data(Vector v) { return v.x(); }\n" + " public Object make() { return new Vector(); }\n" "}\n"; /* The id of the one `label` node called `name` in `file_path`; 0 when there is @@ -17451,6 +17457,19 @@ static int jgen_calls(cbm_store_t *s, int64_t source_id, int64_t target_id, return n; } +/* Edges of `type` into `target_id`. */ +static int jgen_edges_into(cbm_store_t *s, int64_t target_id, const char *type) { + cbm_edge_t *edges = NULL; + int ec = 0; + if (cbm_store_find_edges_by_target_type(s, target_id, type, &edges, &ec) != CBM_STORE_OK) { + return -1; + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return ec; +} + /* One fact of jgen_check: reported, counted, and the check goes on, so a run * shows every fact that does not hold. */ #define JGEN_EXPECT(cond) \ @@ -17475,8 +17494,9 @@ static int jgen_check(cbm_store_t *s, const char *project) { int64_t near = jgen_node(s, project, "near", "Method", "Caller.java", NULL); int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); + int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0); - JGEN_EXPECT(near > 0 && far > 0 && data > 0); + JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0); /* The compiler's members are marked; the hand-written ones are not. */ JGEN_EXPECT(gen_x); JGEN_EXPECT(gen_y); @@ -17489,6 +17509,9 @@ static int jgen_check(cbm_store_t *s, const char *project) { JGEN_EXPECT(jgen_calls(s, far, 0, "y") == 0); /* Neither the accessor nor Spring's field `x`. */ JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); + /* No import names a generated member, so nothing follows one onto it. */ + JGEN_EXPECT(jgen_edges_into(s, acc_y, "IMPORTS") == 0); + JGEN_EXPECT(jgen_calls(s, make, 0, "y") == 0); return failed; } #undef JGEN_EXPECT From 679d1e1af432b0bb1c0873c60668d46ed6736172 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 19:45:08 +0100 Subject: [PATCH 49/97] fix(java): a JVM leaf-name fallback never picks a generated accessor When a JVM LSP callee has no exact node and no Class.method tail match, cbm_pipeline_lsp_target_node takes the one project callable with that leaf name. A record accessor became such a callable: on elasticsearch `Objects.nonNull(x)` (37 CALLS) and `filter(Objects::nonNull)` (230 CALL_REFERENCE) bound a test record's nonNull() accessor. A generated member is registered by qualified name only. It still joins through its owner's Class.method tail, but the leaf-name fallback no longer considers it, either as the unique callable or as a rival that makes a same-named project callable ambiguous, so every other bind is decided exactly as before the accessors existed. The record pipeline fixture gains a record Seen(boolean nonNull) with `Objects.nonNull(o)` and `filter(Objects::nonNull)` callers. Without the change it fails in the sequential, parallel and incremental venues. Signed-off-by: Martin Vogel --- src/pipeline/lsp_resolve.h | 16 ++++++++++---- tests/test_pipeline.c | 45 ++++++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 419f29e266..371cb5536f 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -1281,10 +1281,18 @@ static inline const cbm_gbuf_node_t *cbm_pipeline_lsp_target_node_policy( if (strcmp(cand->label, "Function") != 0 && strcmp(cand->label, "Method") != 0) { continue; } - if (!unique_callable) { - unique_callable = cand; - } else if (unique_callable->id != cand->id) { - callable_ambiguous = true; + /* A generated member (a Java record's implicit accessor) is registered + * by qualified name only: it joins through its owner's Class.method + * tail below, never on its leaf name alone -- `Objects.nonNull(x)` is + * no call of some record's nonNull(). Nor does it make a same-named + * callable ambiguous: the leaf fallback sees the graph as if it were + * not there. */ + if (!cbm_node_props_generated(cand->properties_json)) { + if (!unique_callable) { + unique_callable = cand; + } else if (unique_callable->id != cand->id) { + callable_ambiguous = true; + } } if (!cbm_pipeline_qn_class_method_tail_eq(cand->qualified_name, callee_tail)) { continue; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 940169b77a..b9bfbb0578 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -17379,26 +17379,39 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { * spells the accessor: the import's symbol-name fallback must not bind it * there (no IMPORTS edge), or `new Vector()` in `make` would follow the import * onto the accessor (elasticsearch: `import javax.crypto.spec.PBEKeySpec` - * bound a record's `spec()`). */ + * bound a record's `spec()`). `stat` calls, and `refs` passes, the JDK's + * Objects.nonNull: no project callable is its target, and Seen's accessor -- + * the only project callable called nonNull -- must not be taken for it on its + * leaf name alone (elasticsearch: `Objects.nonNull(x)` and + * `filter(Objects::nonNull)` bound a test record's nonNull()). */ static const char jgen_point_java[] = "package com.example;\n" "public record Point(double x, double y, double z) {\n" " public double z() { return z; }\n" " public double norm() { return Math.sqrt(x * x + y * y + z * z); }\n" "}\n"; +static const char jgen_seen_java[] = "package com.example;\n" + "record Seen(boolean nonNull) {}\n"; static const char jgen_spring_java[] = "package com.example;\n" "public final class Spring {\n" " private double x;\n" " public void put(double v) { this.x = v; }\n" "}\n"; -static const char jgen_caller_java[] = "package com.example;\n" - "import org.external.y.Vector;\n" - "public final class Caller {\n" - " public double near(Point p) { return p.y(); }\n" - " public double far(Vector v) { return v.y(); }\n" - " public double data(Vector v) { return v.x(); }\n" - " public Object make() { return new Vector(); }\n" - "}\n"; +static const char jgen_caller_java[] = + "package com.example;\n" + "import java.util.List;\n" + "import java.util.Objects;\n" + "import org.external.y.Vector;\n" + "public final class Caller {\n" + " public double near(Point p) { return p.y(); }\n" + " public double far(Vector v) { return v.y(); }\n" + " public double data(Vector v) { return v.x(); }\n" + " public Object make() { return new Vector(); }\n" + " public boolean stat(Object o) { return Objects.nonNull(o); }\n" + " public long refs(List xs) {\n" + " return xs.stream().filter(Objects::nonNull).count();\n" + " }\n" + "}\n"; /* The id of the one `label` node called `name` in `file_path`; 0 when there is * none or more than one. `generated` (optional) receives whether its @@ -17487,6 +17500,8 @@ static int jgen_check(cbm_store_t *s, const char *project) { bool gen_y = false; bool gen_z = true; bool gen_norm = true; + bool gen_nn = false; + int64_t acc_nn = jgen_node(s, project, "nonNull", "Method", "Seen.java", &gen_nn); int64_t acc_x = jgen_node(s, project, "x", "Method", "Point.java", &gen_x); int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); @@ -17495,11 +17510,14 @@ static int jgen_check(cbm_store_t *s, const char *project) { int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); - JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0); - JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0); + int64_t stat = jgen_node(s, project, "stat", "Method", "Caller.java", NULL); + int64_t refs = jgen_node(s, project, "refs", "Method", "Caller.java", NULL); + JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0 && acc_nn > 0); + JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); /* The compiler's members are marked; the hand-written ones are not. */ JGEN_EXPECT(gen_x); JGEN_EXPECT(gen_y); + JGEN_EXPECT(gen_nn); JGEN_EXPECT(!gen_z); JGEN_EXPECT(!gen_norm); /* Bound by qualified name through the receiver's type ... */ @@ -17512,12 +17530,17 @@ static int jgen_check(cbm_store_t *s, const char *project) { /* No import names a generated member, so nothing follows one onto it. */ JGEN_EXPECT(jgen_edges_into(s, acc_y, "IMPORTS") == 0); JGEN_EXPECT(jgen_calls(s, make, 0, "y") == 0); + /* The JDK's Objects.nonNull is not Seen's accessor, called or referenced. */ + JGEN_EXPECT(jgen_calls(s, stat, acc_nn, NULL) == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "CALL_REFERENCE") == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "USAGE") == 0); return failed; } #undef JGEN_EXPECT static int jgen_write_fixture(const char *tmp) { write_temp_file(tmp, "Point.java", jgen_point_java); + write_temp_file(tmp, "Seen.java", jgen_seen_java); write_temp_file(tmp, "Spring.java", jgen_spring_java); write_temp_file(tmp, "Caller.java", jgen_caller_java); return 0; From 6e784c38a2ab6d0a7f1f65bc22792cae0d9b9034 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 20:43:28 +0100 Subject: [PATCH 50/97] fix(lsp): name Go init() callers per file in the Go LSP #1910 gives every Go init() its own QN, pkg.init#:, on the def side and in compute_func_qn. The Go LSP computes its enclosing-function QN on its own and still produced the flat pkg.init. The pipeline joins LSP rows to textual calls by exact caller QN, so every LSP resolution inside an init body missed the join: typed calls fell back to the registry resolver and callable references (Register(fn)) were dropped. On kubernetes (8c078bc3), without this commit all 499 lsp_* CALLS from init bodies fell back (import_map, same_module, unique_name) and all 562 CALL_REFERENCE edges from them were lost; with it, the init edges match main's strategy mix. - helpers.c: cbm_go_init_qn is the one formula for all three sides (def extractor, compute_func_qn, Go LSP process_function). The per-file ordinal now walks the top-level declarations with a cursor: ts_node_named_child(root, i) is O(i) per call, so the indexed loop was quadratic on wide generated files. - go_lsp: GoLSPContext carries the file's rel_path. cbm_run_go_lsp, cbm_run_go_lsp_cross and cbm_run_go_lsp_cross_with_registry take it, so the per-file walk, the shared Tier-2 registry walk and the per-file fallback all name init callers like the def side. Tier 3 copies the per-file rows' caller QN, so it follows. - pass_lsp_cross: the dispatch passes rel to the Tier-2 walk and to the per-file fallback (cbm_pxc_run_one_with_php_bindings gains rel_path; the incremental route can run without shared registries). - compute_func_qn: a NULL base QN keeps the flat QN, as the receiver branch above it does. Tests: golsp_init_caller_qn_matches_def (per-file rows and the caller join), golsp_crossfile_init_caller_qn_per_file (both cross entry points), pipeline_go_init_lsp_edges_per_file and its _parallel twin (three inits in two files: lsp_ CALLS and a CALL_REFERENCE on the right init, no cross-wiring, nothing onto a decoy), and pipeline_go_init_dispatch_fallback_names_init_caller. Each fails with its piece reverted; the pipeline tests also fail with the compute_func_qn mirror disabled, which no test covered before. Signed-off-by: Martin Vogel --- internal/cbm/cbm.c | 2 +- internal/cbm/extract_defs.c | 11 +- internal/cbm/extract_unified.c | 13 +- internal/cbm/helpers.c | 87 +++++++----- internal/cbm/helpers.h | 11 +- internal/cbm/lsp/go_lsp.c | 28 ++-- internal/cbm/lsp/go_lsp.h | 10 +- src/pipeline/pass_lsp_cross.c | 26 ++-- src/pipeline/pass_lsp_cross.h | 10 +- tests/test_c_lsp.c | 4 +- tests/test_go_lsp.c | 147 ++++++++++++++++++-- tests/test_parallel.c | 2 +- tests/test_pipeline.c | 238 +++++++++++++++++++++++++++++++++ 13 files changed, 503 insertions(+), 86 deletions(-) diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 23b871dbc0..59c2c20c3e 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -3919,7 +3919,7 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C uint64_t lsp_start = now_ns(); if (!result->lsp_skipped) { if (language == CBM_LANG_GO) { - cbm_run_go_lsp(a, result, source, source_len, root); + cbm_run_go_lsp(a, result, source, source_len, root, rel_path); } if (language == CBM_LANG_C || language == CBM_LANG_CPP || language == CBM_LANG_CUDA) { if (!cbm_run_c_lsp_with_test_owners(a, result, source, source_len, root, diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 8cf268b11c..ef86c332d4 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -7069,14 +7069,13 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * above the function — the QN is node identity, and a line suffix would * churn nodes on every unrelated insertion. Nothing ever joins on the * plain QN — calling init explicitly is illegal in Go — and the - * call-scope side (compute_func_qn in extract_unified.c) computes the - * same cbm_go_init_ordinal so init-body calls keep their attribution. */ + * call-scope side (compute_func_qn in extract_unified.c) and the Go LSP + * (process_function in lsp/go_lsp.c) name it through the same + * cbm_go_init_qn, so init-body calls keep their attribution. */ if (ctx->language == CBM_LANG_GO && strcmp(def.label, "Function") == 0 && strcmp(name, "init") == 0) { - const char *base = strrchr(ctx->rel_path, '/'); - base = base ? base + 1 : ctx->rel_path; - def.qualified_name = cbm_arena_sprintf(a, "%s#%s:%d", def.qualified_name, base, - cbm_go_init_ordinal(node, ctx->source)); + def.qualified_name = + cbm_go_init_qn(a, def.qualified_name, ctx->rel_path, node, ctx->source); } // C++/CUDA: out-of-line method definition (`Foo::bar` in a .cc/.cpp). The diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 39b20f460b..e8edbd448d 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1136,17 +1136,18 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan /* Go init(): the def extractor folds the file basename and the init's * per-file ordinal into the QN so every init in a package survives the * upsert (#1910, the #495 cfg-twin pattern; ordinal, not line, so the QN - * is stable under edits above the function). Mirror the exact formula - * here, or init-body calls fall back to File-node attribution + * is stable under edits above the function). Name it through the same + * cbm_go_init_qn, or init-body calls fall back to File-node attribution * (calls_find_source). */ if (ctx->language == CBM_LANG_GO && strcmp(name, "init") == 0 && strcmp(ts_node_type(node), "function_declaration") == 0) { const char *base_qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name, ctx->language); - const char *base = strrchr(ctx->rel_path, '/'); - base = base ? base + 1 : ctx->rel_path; - return cbm_arena_sprintf(ctx->arena, "%s#%s:%d", base_qn, base, - cbm_go_init_ordinal(node, ctx->source)); + /* NULL only on allocation failure: fall through to the flat QN, as + * the receiver branch above does. */ + if (base_qn) { + return cbm_go_init_qn(ctx->arena, base_qn, ctx->rel_path, node, ctx->source); + } } /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index ebb653e402..d6a8e73b97 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -2330,48 +2330,71 @@ int cbm_classify_string(const char *str, int len) { return NOT_FOUND; } -/* Flatten a JS/TS `template_string` node into plain text (issue #1006). - * String fragments are kept verbatim; each ${...} substitution becomes the - * "{}" placeholder so client URLs built from template literals share the - * canonical parameter shape of server-side route paths - * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields - * no text or exceeds the route-sized buffer. */ /* 1-based source-order ordinal of a top-level Go `init` function_declaration - * among the file's `init`s. Both the definition extractor and compute_func_qn - * fold it into the QN (`pkg.init#file.go:N`, #1910), so it must be computed - * from the tree on both sides with this one formula. Unlike a line number, - * the ordinal is stable under edits above the function — QNs are node - * identity, and a line-based suffix would churn nodes on every unrelated - * insertion (the #495 cfg suffix and the GoogleTest derivation are both - * edit-stable for the same reason). */ -int cbm_go_init_ordinal(TSNode fn_node, const char *source) { + * among the file's `init`s. Unlike a line number, the ordinal is stable under + * edits above the function — QNs are node identity, and a line-based suffix + * would churn nodes on every unrelated insertion (the #495 cfg suffix and the + * GoogleTest derivation are both edit-stable for the same reason). + * One cursor pass over the top-level declarations: ts_node_named_child(root, i) + * is O(i), so an indexed loop is O(n^2) per init on a wide file, and generated + * Go (.pb.go, tables) carries thousands of top-level declarations next to its + * init(). */ +static int go_init_ordinal(TSNode fn_node, const char *source) { TSNode root = fn_node; for (TSNode p = ts_node_parent(root); !ts_node_is_null(p); p = ts_node_parent(p)) { root = p; } int ordinal = 0; - uint32_t n = ts_node_named_child_count(root); - for (uint32_t i = 0; i < n; i++) { - TSNode child = ts_node_named_child(root, i); - if (strcmp(ts_node_type(child), "function_declaration") != 0) { - continue; - } - TSNode name = ts_node_child_by_field_name(child, TS_FIELD("name")); - if (ts_node_is_null(name)) { - continue; - } - uint32_t start = ts_node_start_byte(name); - if (ts_node_end_byte(name) - start != 4 || memcmp(source + start, "init", 4) != 0) { - continue; - } - ordinal++; - if (ts_node_eq(child, fn_node)) { - return ordinal; - } + TSTreeCursor cur = ts_tree_cursor_new(root); + if (ts_tree_cursor_goto_first_child(&cur)) { + do { + TSNode child = ts_tree_cursor_current_node(&cur); + if (strcmp(ts_node_type(child), "function_declaration") != 0) { + continue; + } + TSNode name = ts_node_child_by_field_name(child, TS_FIELD("name")); + if (ts_node_is_null(name)) { + continue; + } + uint32_t start = ts_node_start_byte(name); + if (ts_node_end_byte(name) - start != 4 || memcmp(source + start, "init", 4) != 0) { + continue; + } + ordinal++; + if (ts_node_eq(child, fn_node)) { + break; + } + } while (ts_tree_cursor_goto_next_sibling(&cur)); } + ts_tree_cursor_delete(&cur); return ordinal; } +/* Qualified name of a Go `init` function (#1910). Go allows any number of + * init() per package — even several in one file — so the flat `pkg.init` + * collides and the graph upsert keeps one node. The QN folds in the file + * basename and the init's per-file ordinal: `pkg.init#file.go:N`. Every side + * that names an init calls this one function — the definition extractor, the + * call-scope side (compute_func_qn) and the Go LSP's enclosing-function QN — + * so the three cannot drift. A NULL base QN or path keeps base_qn as is. */ +const char *cbm_go_init_qn(CBMArena *a, const char *base_qn, const char *rel_path, TSNode fn_node, + const char *source) { + if (!base_qn || !rel_path) { + return base_qn; + } + const char *base = strrchr(rel_path, '/'); + base = base ? base + 1 : rel_path; + const char *qn = + cbm_arena_sprintf(a, "%s#%s:%d", base_qn, base, go_init_ordinal(fn_node, source)); + return qn ? qn : base_qn; +} + +/* Flatten a JS/TS `template_string` node into plain text (issue #1006). + * String fragments are kept verbatim; each ${...} substitution becomes the + * "{}" placeholder so client URLs built from template literals share the + * canonical parameter shape of server-side route paths + * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields + * no text or exceeds the route-sized buffer. */ const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source) { enum { TPL_BUF = 512 }; char buf[TPL_BUF]; diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 13dd1dc2bc..4c96cb5f05 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -281,16 +281,19 @@ bool cbm_is_loop_node_type(const char *kind); // Is this a module-level node? (not nested inside function/class body) bool cbm_is_module_level(TSNode node, CBMLanguage lang); -/* 1-based source-order ordinal of a Go `init` among its file's top-level - * `init` function_declarations (QN disambiguation, #1910). */ -int cbm_go_init_ordinal(TSNode fn_node, const char *source); - // Same check, but the node's PARENT is supplied directly — avoids the // O(n) ts_node_parent rescan. Use at call sites iterating a known // parent's children (the common case). `parent` is the parent of the // node being classified. bool cbm_is_module_level_p(TSNode parent, CBMLanguage lang); +/* QN of a Go `init` function_declaration (#1910): base_qn + "#:", + * N = its 1-based ordinal among the file's top-level `init`s. The ONE formula + * for the def extractor, compute_func_qn and the Go LSP's enclosing-function + * QN. A NULL base_qn or rel_path returns base_qn unchanged. */ +const char *cbm_go_init_qn(CBMArena *a, const char *base_qn, const char *rel_path, TSNode fn_node, + const char *source); + // --- FQN computation --- // Compute qualified name: project.rel_path_parts.name diff --git a/internal/cbm/lsp/go_lsp.c b/internal/cbm/lsp/go_lsp.c index 34fad4b9e3..3de20c0d26 100644 --- a/internal/cbm/lsp/go_lsp.c +++ b/internal/cbm/lsp/go_lsp.c @@ -1994,6 +1994,8 @@ static void process_function(GoLSPContext *ctx, TSNode func_node) { // resolver. Since #1909 receiver-qualified Go method QNs, that shared // form is package_qn.ReceiverType.method for methods (the ONE-formula // contract: cbm_go_receiver_type_name) and package_qn.func for functions. + // A package may hold any number of init() functions, so each one is + // package_qn.init#: (#1910, cbm_go_init_qn — the same formula). ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name); if (strcmp(ts_node_type(func_node), "method_declaration") == 0) { TSNode recv = ts_node_child_by_field_name(func_node, "receiver", 8); @@ -2004,6 +2006,9 @@ static void process_function(GoLSPContext *ctx, TSNode func_node) { recv_type, func_name); } } + } else if (strcmp(func_name, "init") == 0) { + ctx->enclosing_func_qn = cbm_go_init_qn(ctx->arena, ctx->enclosing_func_qn, ctx->rel_path, + func_node, ctx->source); } // Push function scope @@ -2261,7 +2266,7 @@ static void go_registry_with_stdlib(CBMTypeRegistry *reg, CBMArena *arena) { // --- Entry point: build registry from file defs + run LSP --- void cbm_run_go_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, - TSNode root) { + TSNode root, const char *rel_path) { CBMTypeRegistry reg; cbm_registry_init(®, arena); @@ -2619,6 +2624,7 @@ void cbm_run_go_lsp(CBMArena *arena, CBMFileResult *result, const char *source, // Phase 2: Build LSP context and run GoLSPContext lsp_ctx; go_lsp_init(&lsp_ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls); + lsp_ctx.rel_path = rel_path; // Add imports for (int i = 0; i < result->imports.count; i++) { @@ -3218,9 +3224,9 @@ static void extract_type_params_from_ast(CBMArena *arena, CBMTypeRegistry *reg, extern const TSLanguage *tree_sitter_go(void); void cbm_run_go_lsp_cross(CBMArena *arena, const char *source, int source_len, - const char *module_qn, CBMLSPDef *defs, int def_count, - const char **import_names, const char **import_qns, int import_count, - TSTree *cached_tree, CBMResolvedCallArray *out) { + const char *module_qn, const char *rel_path, CBMLSPDef *defs, + int def_count, const char **import_names, const char **import_qns, + int import_count, TSTree *cached_tree, CBMResolvedCallArray *out) { if (!source || source_len <= 0) return; @@ -3483,6 +3489,7 @@ void cbm_run_go_lsp_cross(CBMArena *arena, const char *source, int source_len, // 4. Build LSP context and run GoLSPContext ctx; go_lsp_init(&ctx, arena, source, source_len, ®, module_qn, out); + ctx.rel_path = rel_path; for (int i = 0; i < import_count; i++) { if (import_names[i] && import_qns[i]) { @@ -3624,10 +3631,10 @@ CBMTypeRegistry *cbm_go_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, i } void cbm_run_go_lsp_cross_with_registry(CBMArena *arena, const char *source, int source_len, - const char *module_qn, CBMTypeRegistry *reg, - const char **import_names, const char **import_qns, - int import_count, TSTree *cached_tree, - CBMResolvedCallArray *out) { + const char *module_qn, const char *rel_path, + CBMTypeRegistry *reg, const char **import_names, + const char **import_qns, int import_count, + TSTree *cached_tree, CBMResolvedCallArray *out) { if (!source || source_len <= 0 || !reg) return; @@ -3653,6 +3660,7 @@ void cbm_run_go_lsp_cross_with_registry(CBMArena *arena, const char *source, int GoLSPContext ctx; go_lsp_init(&ctx, arena, source, source_len, reg, module_qn, out); + ctx.rel_path = rel_path; for (int i = 0; i < import_count; i++) { if (import_names[i] && import_qns[i]) { go_lsp_add_import(&ctx, import_names[i], import_qns[i]); @@ -3802,8 +3810,8 @@ void cbm_batch_go_lsp_cross(CBMArena *arena, CBMBatchGoLSPFile *files, int file_ // Delegate to existing per-file function cbm_run_go_lsp_cross(&file_arena, file->source, file->source_len, file->module_qn, - file->defs, file->def_count, file->import_names, file->import_qns, - file->import_count, file->cached_tree, &file_out); + file->rel_path, file->defs, file->def_count, file->import_names, + file->import_qns, file->import_count, file->cached_tree, &file_out); // Copy results to output arena (must outlive per-file arena) if (file_out.count > 0) { diff --git a/internal/cbm/lsp/go_lsp.h b/internal/cbm/lsp/go_lsp.h index 5874cbeefa..3b504d007d 100644 --- a/internal/cbm/lsp/go_lsp.h +++ b/internal/cbm/lsp/go_lsp.h @@ -22,6 +22,7 @@ typedef struct { // Current function context const char* enclosing_func_qn; const char* package_qn; // module QN for this file + const char* rel_path; // repo-relative file path: init() QN suffix (#1910); NULL = flat // Output: resolved calls accumulate here CBMResolvedCallArray* resolved_calls; @@ -63,8 +64,10 @@ const CBMRegisteredFunc* go_lookup_field_or_method(GoLSPContext* ctx, // Entry point: build registry from file's own defs + run LSP resolution. // Called from cbm_extract_file() after definitions and imports are extracted. +// rel_path is the file's repo-relative path: it names the file's init() +// functions exactly as the def extractor does (cbm_go_init_qn, #1910). void cbm_run_go_lsp(CBMArena* arena, CBMFileResult* result, - const char* source, int source_len, TSNode root); + const char* source, int source_len, TSNode root, const char* rel_path); // Register Go stdlib types and functions into a registry. // Auto-generated by scripts/gen-go-stdlib.go. @@ -108,11 +111,14 @@ typedef struct { // Re-parses source with tree-sitter internally. // defs includes both file-local and cross-file definitions. // import_names/import_qns are parallel arrays of length import_count. +// rel_path: the file's repo-relative path, for its init() QNs (#1910); NULL +// leaves init callers flat (they then miss the caller join). // Results written to out (arena-allocated). void cbm_run_go_lsp_cross( CBMArena* arena, const char* source, int source_len, const char* module_qn, + const char* rel_path, CBMLSPDef* defs, int def_count, const char** import_names, const char** import_qns, int import_count, TSTree* cached_tree, // NULL = parse internally @@ -131,6 +137,7 @@ void cbm_run_go_lsp_cross_with_registry( CBMArena* arena, const char* source, int source_len, const char* module_qn, + const char* rel_path, // see cbm_run_go_lsp_cross CBMTypeRegistry* reg, // pre-built, finalized, READ-ONLY const char** import_names, const char** import_qns, int import_count, TSTree* cached_tree, // NULL = parse internally @@ -156,6 +163,7 @@ typedef struct { const char* source; int source_len; const char* module_qn; + const char* rel_path; // repo-relative path (init() QNs, #1910); NULL = flat TSTree* cached_tree; // from TSTree caching (NULL = parse internally) CBMLSPDef* defs; // combined file-local + cross-file defs int def_count; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 5aa9551123..ed8af87d92 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -2079,14 +2079,15 @@ static CBMRustLSPDef *pxc_lspdefs_to_rust(CBMArena *arena, const CBMLSPDef *defs void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int source_len, const char *module_qn, CBMLSPDef *defs, int def_count, const char **imp_names, const char **imp_qns, int imp_count) { - cbm_pxc_run_one_with_php_bindings(lang, r, source, source_len, module_qn, defs, def_count, + cbm_pxc_run_one_with_php_bindings(lang, r, source, source_len, module_qn, NULL, defs, def_count, imp_names, imp_qns, imp_count, NULL); } void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const char *source, - int source_len, const char *module_qn, CBMLSPDef *defs, - int def_count, const char **imp_names, const char **imp_qns, - int imp_count, const CBMPHPImportBindings *bindings) { + int source_len, const char *module_qn, const char *rel_path, + CBMLSPDef *defs, int def_count, const char **imp_names, + const char **imp_qns, int imp_count, + const CBMPHPImportBindings *bindings) { TSTree *tree = r->cached_tree; /* may be NULL — LSP re-parses then */ CBMArena scratch; @@ -2098,8 +2099,8 @@ void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const switch (lang) { case CBM_LANG_GO: - cbm_run_go_lsp_cross(&scratch, source, source_len, module_qn, defs, def_count, imp_names, - imp_qns, imp_count, tree, &out); + cbm_run_go_lsp_cross(&scratch, source, source_len, module_qn, rel_path, defs, def_count, + imp_names, imp_qns, imp_count, tree, &out); break; case CBM_LANG_C: case CBM_LANG_CPP: @@ -2293,9 +2294,9 @@ void cbm_pxc_dispatch_file_with_php_bindings( * lsp_unresolved entries -- no parse, no AST walk -- then the * AST walk against the overlay (chained to the sealed base). */ cbm_go_fast_resolve_qualified_calls(result, prebuilt, imp_keys, imp_vals, imp_count); - cbm_run_go_lsp_cross_with_registry(&scratch, source, source_len, def_module, &overlay, - imp_keys, imp_vals, imp_count, result->cached_tree, - &out); + cbm_run_go_lsp_cross_with_registry(&scratch, source, source_len, def_module, rel, + &overlay, imp_keys, imp_vals, imp_count, + result->cached_tree, &out); used_prebuilt = true; break; case CBM_LANG_PYTHON: { @@ -2429,7 +2430,7 @@ void cbm_pxc_dispatch_file_with_php_bindings( pxc_append_synthetic_calls(&result->arena, &result->calls, &synthetic_calls); pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch); } else { - cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, + cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, rel, file_defs, file_def_count, imp_keys, imp_vals, imp_count, bindings); } @@ -2441,8 +2442,9 @@ void cbm_pxc_dispatch_file_with_php_bindings( cbm_pxc_run_one_ts(result, source, source_len, def_module, file_defs, file_def_count, imp_keys, imp_vals, imp_count, js, jsx, dts); } else { - cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, file_defs, - file_def_count, imp_keys, imp_vals, imp_count, bindings); + cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, rel, + file_defs, file_def_count, imp_keys, imp_vals, imp_count, + bindings); } free(filtered); } diff --git a/src/pipeline/pass_lsp_cross.h b/src/pipeline/pass_lsp_cross.h index bcbe304251..51964bc41e 100644 --- a/src/pipeline/pass_lsp_cross.h +++ b/src/pipeline/pass_lsp_cross.h @@ -216,10 +216,14 @@ void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int const char *module_qn, CBMLSPDef *defs, int def_count, const char **imp_names, const char **imp_qns, int imp_count); +/* rel_path: the file's repo-relative path. Go needs it to name the file's + * init() functions as the def extractor does (#1910); cbm_pxc_run_one passes + * NULL (Go init() callers then stay flat). */ void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const char *source, - int source_len, const char *module_qn, CBMLSPDef *defs, - int def_count, const char **imp_names, const char **imp_qns, - int imp_count, const CBMPHPImportBindings *bindings); + int source_len, const char *module_qn, const char *rel_path, + CBMLSPDef *defs, int def_count, const char **imp_names, + const char **imp_qns, int imp_count, + const CBMPHPImportBindings *bindings); /* TS / JS / JSX / TSX variant with explicit dialect flags. */ void cbm_pxc_run_one_ts(CBMFileResult *r, const char *source, int source_len, const char *module_qn, diff --git a/tests/test_c_lsp.c b/tests/test_c_lsp.c index a4e46c7bce..fc7bf2f1ee 100644 --- a/tests/test_c_lsp.c +++ b/tests/test_c_lsp.c @@ -16000,8 +16000,8 @@ TEST(seal_go_shared_registry_readonly) { const char *src = "package main\nfunc helper(x int) int { return x + 1 }\n" "func caller() int { return helper(1) }\n"; CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.mod", reg, NULL, NULL, - 0, NULL, &out); + cbm_run_go_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.mod", NULL, reg, NULL, + NULL, 0, NULL, &out); ASSERT_EQ(reg->func_count, fb); ASSERT_EQ(reg->type_count, tb); cbm_arena_destroy(&arena); diff --git a/tests/test_go_lsp.c b/tests/test_go_lsp.c index e852f4aca1..900c3c8822 100644 --- a/tests/test_go_lsp.c +++ b/tests/test_go_lsp.c @@ -1063,7 +1063,7 @@ TEST(golsp_crossfile_method_dispatch) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names, imp_qns, 1, NULL, &out); ASSERT_GTE(find_resolved_arr(&out, "doQuery", "Connect"), 0); @@ -1109,7 +1109,7 @@ TEST(golsp_crossfile_return_type_chain) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names, imp_qns, 1, NULL, &out); ASSERT_GTE(find_resolved_arr(&out, "showUser", "Name"), 0); @@ -1149,7 +1149,7 @@ TEST(golsp_crossfile_interface_dispatch) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names, imp_qns, 1, NULL, &out); int idx = find_resolved_arr_confident(&out, "handler", "Bind"); @@ -1205,7 +1205,7 @@ TEST(golsp_crossfile_interface_field_chain) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 6, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 6, imp_names, imp_qns, 1, NULL, &out); int idx = find_resolved_arr_confident(&out, "process", "Bind"); @@ -1245,7 +1245,7 @@ TEST(golsp_crossfile_map_index) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 3, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 3, imp_names, imp_qns, 1, NULL, &out); int idx = find_resolved_arr_confident(&out, "dispatch", "ServeHTTP"); @@ -1275,7 +1275,7 @@ TEST(golsp_crossfile_stdlib_interface) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 1, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 1, imp_names, imp_qns, 1, NULL, &out); ASSERT_GTE(find_resolved_arr_confident(&out, "process", "Done"), 0); @@ -1349,7 +1349,7 @@ TEST(golsp_crossfile_aliased_field_requal) { CBMTypeRegistry *reg = cbm_go_build_cross_registry(&arena, defs, 6); ASSERT_NOT_NULL(reg); - cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, + cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", NULL, reg, imp_names, imp_qns, 2, NULL, &out); int svc_idx = find_resolved_arr_confident(&out, "callSvc", "Svc.Ping"); @@ -1417,7 +1417,7 @@ TEST(golsp_crossfile_local_interface_single_impl) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 5, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 5, imp_names, imp_qns, 1, NULL, &out); int idxGet = find_resolved_arr_confident(&out, "process", "Get"); @@ -1433,6 +1433,133 @@ TEST(golsp_crossfile_local_interface_single_impl) { PASS(); } +/* ── Go init(): per-file caller QN (#1910) ──────────────────────── */ + +/* Every init() of a package is its own node, pkg.init#: + * (cbm_go_init_qn). The LSP's enclosing-function QN must be exactly that QN: + * the pipeline joins LSP rows to textual calls by caller QN, so a flat + * pkg.init loses every LSP resolution inside an init body. Two inits in one + * file pin the ordinal; the method name exists on both types, so only the + * typed receiver tells the targets apart. */ +TEST(golsp_init_caller_qn_matches_def) { + const char *source = "package main\n\n" + "type Codec struct{}\n\n" + "func (c *Codec) register() {}\n\n" + "type Store struct{}\n\n" + "func (s *Store) register() {}\n\n" + "func init() {\n\tc := &Codec{}\n\tc.register()\n}\n\n" + "func init() {\n\ts := &Store{}\n\ts.register()\n}\n"; + CBMFileResult *r = extract_go(source); + ASSERT_NOT_NULL(r); + const char *init1 = NULL; + const char *init2 = NULL; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->name && strcmp(d->name, "init") == 0) { + if (!init1) { + init1 = d->qualified_name; + } else { + init2 = d->qualified_name; + } + } + } + ASSERT_NOT_NULL(init1); + ASSERT_NOT_NULL(init2); + ASSERT_NOT_NULL(strstr(init1, ".init#main.go:1")); + ASSERT_NOT_NULL(strstr(init2, ".init#main.go:2")); + + int a = require_resolved(r, "init", "Codec.register"); + ASSERT_GTE(a, 0); + ASSERT_STR_EQ(r->resolved_calls.items[a].caller_qn, init1); + int b = require_resolved(r, "init", "Store.register"); + ASSERT_GTE(b, 0); + ASSERT_STR_EQ(r->resolved_calls.items[b].caller_qn, init2); + + /* The join itself: each textual register() call (raw callee text + * "c.register" / "s.register") finds its own init's row. */ + int joined = 0; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + if (!c->callee_name || !strstr(c->callee_name, ".register")) { + continue; + } + const CBMResolvedCall *rc = cbm_pipeline_find_lsp_resolution(&r->resolved_calls, c, false); + ASSERT_NOT_NULL(rc); + ASSERT_STR_EQ(rc->caller_qn, c->enclosing_func_qn); + ASSERT_NOT_NULL( + strstr(rc->callee_qn, strcmp(c->enclosing_func_qn, init1) == 0 ? "Codec" : "Store")); + joined++; + } + ASSERT_EQ(joined, 2); + cbm_free_result(r); + PASS(); +} + +/* Cross-file twin: both cross entry points (per-file defs, and the shared + * Tier-2 registry the pipeline uses) name the init caller from rel_path. */ +TEST(golsp_crossfile_init_caller_qn_per_file) { + const char *source = "package reg\n\n" + "func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n\n" + "func init() {\n\ts := NewStore()\n\ts.Register()\n}\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.reg.NewCodec", + .short_name = "NewCodec", + .label = "Function", + .def_module_qn = "test.reg", + .return_types = "*Codec"}, + {.qualified_name = "test.reg.Codec", + .short_name = "Codec", + .label = "Class", + .def_module_qn = "test.reg"}, + {.qualified_name = "test.reg.Codec.Register", + .short_name = "Register", + .label = "Method", + .def_module_qn = "test.reg", + .receiver_type = "test.reg.Codec"}, + {.qualified_name = "test.reg.NewStore", + .short_name = "NewStore", + .label = "Function", + .def_module_qn = "test.reg", + .return_types = "*Store"}, + {.qualified_name = "test.reg.Store", + .short_name = "Store", + .label = "Class", + .def_module_qn = "test.reg"}, + {.qualified_name = "test.reg.Store.Register", + .short_name = "Register", + .label = "Method", + .def_module_qn = "test.reg", + .receiver_type = "test.reg.Store"}, + }; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.reg", "reg/b.go", defs, 6, NULL, + NULL, 0, NULL, &out); + int c1 = find_resolved_arr_confident(&out, "init", "Codec.Register"); + ASSERT_GTE(c1, 0); + ASSERT_STR_EQ(out.items[c1].caller_qn, "test.reg.init#b.go:1"); + int s1 = find_resolved_arr_confident(&out, "init", "Store.Register"); + ASSERT_GTE(s1, 0); + ASSERT_STR_EQ(out.items[s1].caller_qn, "test.reg.init#b.go:2"); + + CBMResolvedCallArray shared_out = {0}; + CBMTypeRegistry *reg = cbm_go_build_cross_registry(&arena, defs, 6); + ASSERT_NOT_NULL(reg); + cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.reg", "reg/b.go", + reg, NULL, NULL, 0, NULL, &shared_out); + int c2 = find_resolved_arr_confident(&shared_out, "init", "Codec.Register"); + ASSERT_GTE(c2, 0); + ASSERT_STR_EQ(shared_out.items[c2].caller_qn, "test.reg.init#b.go:1"); + int s2 = find_resolved_arr_confident(&shared_out, "init", "Store.Register"); + ASSERT_GTE(s2, 0); + ASSERT_STR_EQ(shared_out.items[s2].caller_qn, "test.reg.init#b.go:2"); + + cbm_arena_destroy(&arena); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ SUITE(go_lsp) { @@ -1545,4 +1672,8 @@ SUITE(go_lsp) { RUN_TEST(golsp_crossfile_map_index); RUN_TEST(golsp_crossfile_stdlib_interface); RUN_TEST(golsp_crossfile_local_interface_single_impl); + + /* Go init(): per-file caller QN (#1910) */ + RUN_TEST(golsp_init_caller_qn_matches_def); + RUN_TEST(golsp_crossfile_init_caller_qn_per_file); } diff --git a/tests/test_parallel.c b/tests/test_parallel.c index a641e7e0e5..993c646eb7 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -5476,7 +5476,7 @@ TEST(parallel_php_external_row_replaces_only_its_exact_occurrence) { const char *keys[] = {"LocalRequest"}; const char *vals[] = {project_type}; cbm_pxc_run_one_with_php_bindings(CBM_LANG_PHP, r, source, (int)strlen(source), "p.main", - defs, 2, keys, vals, 1, &bindings); + NULL, defs, 2, keys, vals, 1, &bindings); int external_count = 0, project_count = 0; for (int i = 0; i < r->resolved_calls.count; i++) { const CBMResolvedCall *row = &r->resolved_calls.items[i]; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 8b413066d8..9f32c3d068 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -10298,6 +10298,194 @@ TEST(pipeline_go_multi_init_nodes_survive) { PASS(); } +/* Edges of `edge_type` whose source and target QNs end with the given + * suffixes (go_calls_between for any edge type, without the properties). */ +static int go_edges_between(cbm_store_t *s, const char *project, const char *edge_type, + const char *caller_suffix, const char *callee_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, edge_type, &edges, &edge_count); + size_t cs = strlen(caller_suffix); + size_t ts = strlen(callee_suffix); + int hits = 0; + 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.qualified_name) { + size_t sl = strlen(src.qualified_name); + size_t tl = strlen(tgt.qualified_name); + if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 && + strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0) { + hits++; + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + return hits; +} + +/* Fixture for the #1910 Go init() LSP-join probes (sequential and parallel + * twins). Three init() in two files of one package -- two in b.go, so the + * per-file ordinal is pinned too -- each calling a method that only the Go + * LSP can bind: the receiver's type comes from a constructor in another + * file, and the method name also exists on a second package type and on + * other.Decoy, so no short-name fallback lands on the right target. a.go's + * init also passes a handler by value (add("a", handleA): a CALL_REFERENCE, + * which exists only as an LSP row). pad_files > 0 adds filler Go files past + * MIN_FILES_FOR_PARALLEL. */ +static void write_go_init_lsp_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fxinit\n\ngo 1.22\n"); + write_temp_file(tmp, "reg/codec.go", + "package reg\n" + "\n" + "type Codec struct{ n int }\n" + "\n" + "func NewCodec() *Codec { return &Codec{} }\n" + "\n" + "func (c *Codec) register() { c.n++ }\n"); + write_temp_file(tmp, "reg/store.go", + "package reg\n" + "\n" + "type Store struct{ open bool }\n" + "\n" + "func NewStore() *Store { return &Store{} }\n" + "\n" + "func (s *Store) register() { s.open = true }\n" + "\n" + "func (s *Store) warm() {}\n"); + write_temp_file(tmp, "reg/a.go", + "package reg\n" + "\n" + "var handlers = map[string]func(){}\n" + "\n" + "func handleA() {}\n" + "\n" + "func add(name string, fn func()) { handlers[name] = fn }\n" + "\n" + "func init() {\n" + "\tc := NewCodec()\n" + "\tc.register()\n" + "\tadd(\"a\", handleA)\n" + "}\n"); + write_temp_file(tmp, "reg/b.go", + "package reg\n" + "\n" + "func init() {\n" + "\ts := NewStore()\n" + "\ts.register()\n" + "}\n" + "\n" + "func init() {\n" + "\ts := NewStore()\n" + "\ts.warm()\n" + "}\n"); + write_temp_file(tmp, "other/other.go", + "package other\n" + "\n" + "type Decoy struct{}\n" + "\n" + "func (d *Decoy) register() {}\n" + "\n" + "func (d *Decoy) warm() {}\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); + } +} + +/* Each init's LSP-resolved edges start at that init's own node. */ +static int assert_go_init_lsp_edges(cbm_store_t *s, const char *project) { + const char *lsp = "\"strategy\":\"lsp_"; + ASSERT_GTE(go_calls_between(s, project, ".reg.init#a.go:1", ".reg.Codec.register", lsp), 1); + ASSERT_GTE(go_calls_between(s, project, ".reg.init#b.go:1", ".reg.Store.register", lsp), 1); + ASSERT_GTE(go_calls_between(s, project, ".reg.init#b.go:2", ".reg.Store.warm", lsp), 1); + ASSERT_GTE(go_edges_between(s, project, "CALL_REFERENCE", ".reg.init#a.go:1", ".reg.handleA"), + 1); + /* No cross-wiring between the inits, and nothing onto the decoy. */ + ASSERT_EQ(go_calls_between(s, project, ".reg.init#a.go:1", ".reg.Store.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:1", ".reg.Codec.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:2", ".reg.Store.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#a.go:1", ".other.Decoy.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:1", ".other.Decoy.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:2", ".other.Decoy.warm", NULL), 0); + return 0; +} + +/* #1910: the def extractor and compute_func_qn name each init + * pkg.init#:; the Go LSP must name its caller the same way + * (cbm_go_init_qn), or every LSP resolution inside an init body loses the + * exact caller-QN join and falls back to the registry resolver. */ +TEST(pipeline_go_init_lsp_edges_per_file) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_lsp_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_init_lsp_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_init_lsp.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_init_lsp_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 (resolve_worker's Tier-2 dispatch). */ +TEST(pipeline_go_init_lsp_edges_per_file_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_lsp_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_init_lsp_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_init_lsp_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_init_lsp_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 @@ -16256,6 +16444,53 @@ TEST(pipeline_python_init_import_probe_failure_falls_back) { PASS(); } +/* #1910: with no shared Go registry (the incremental route can pass NULL + * cross_registries) the dispatch takes the per-file fallback, which must name + * each init() caller from the file path exactly as the def extractor does. */ +TEST(pipeline_go_init_dispatch_fallback_names_init_caller) { + const char *source = "package reg\n\n" + "func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n\n" + "func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.reg.NewCodec", + .short_name = "NewCodec", + .label = "Function", + .def_module_qn = "test.reg", + .return_types = "*Codec", + .lang = CBM_LANG_GO}, + {.qualified_name = "test.reg.Codec", + .short_name = "Codec", + .label = "Class", + .def_module_qn = "test.reg", + .lang = CBM_LANG_GO}, + {.qualified_name = "test.reg.Codec.Register", + .short_name = "Register", + .label = "Method", + .def_module_qn = "test.reg", + .receiver_type = "test.reg.Codec", + .lang = CBM_LANG_GO}, + }; + CBMFileResult result = {0}; + cbm_arena_init(&result.arena); + cbm_pxc_dispatch_file(CBM_LANG_GO, &result, source, (int)strlen(source), "reg/b.go", "test.reg", + NULL, NULL, defs, 3, NULL, NULL, 0, NULL, NULL); + int first = 0; + int second = 0; + for (int i = 0; i < result.resolved_calls.count; i++) { + const CBMResolvedCall *rc = &result.resolved_calls.items[i]; + if (!rc->caller_qn || !rc->callee_qn || rc->confidence <= 0 || + strcmp(rc->callee_qn, "test.reg.Codec.Register") != 0) { + continue; + } + first += strcmp(rc->caller_qn, "test.reg.init#b.go:1") == 0; + second += strcmp(rc->caller_qn, "test.reg.init#b.go:2") == 0; + } + cbm_arena_destroy(&result.arena); + ASSERT_EQ(first, 1); + ASSERT_EQ(second, 1); + PASS(); +} + /* #1277: typed instance fields of a Python class imported from another file. * Counts CALLS edges caller -> callee (QN suffixes) whose properties name an * LSP strategy. */ @@ -28171,6 +28406,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_init_register_scope_failure_parallel); RUN_TEST(pipeline_python_init_import_filter_precedence); RUN_TEST(pipeline_python_init_import_probe_failure_falls_back); + RUN_TEST(pipeline_go_init_dispatch_fallback_names_init_caller); RUN_TEST(pipeline_issue1527_python_raw_memo_failure_sequential); RUN_TEST(pipeline_issue1527_python_raw_memo_failure_parallel); RUN_TEST(pipeline_issue1527_python_cross_memo_failure_sequential); @@ -28291,6 +28527,8 @@ 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_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_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); RUN_TEST(pipeline_rust_call_never_binds_another_language); From 4316e93c2be30d3cbabe02ccf3263ce15bbc0da0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 23:44:34 +0100 Subject: [PATCH 51/97] 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 24ba07cd43..9cdfb17e34 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 74e301d42e8a5ab32aa31d46e21637fd9d241a6f Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 28 Oct 2026 00:45:27 +0100 Subject: [PATCH 52/97] fix(daemon): end a permanent watch whose project was deleted Under `daemon start` a watch now outlives its last session. delete_project from another session or the CLI removes the project's DB but meets no owner of the watch: daemon MCP sessions carry no watcher pointer, so nothing unwatches it, and the next change in that root re-indexed the deleted project. When a permanent watch has no subscribers, no job for its project is running and its DB is gone, the watcher callback now removes the watch and skips, the rule a session refresh already applies to a deleted project. The running-job guard is needed because a full reindex unlinks the DB while it rebuilds it: a second change during that rebuild must join the job, not end the watch. Session-owned watches are unchanged. Tests: daemon_application_permanent_watch_ends_when_project_db_is_deleted (RED before: the deleted project was re-indexed) and daemon_application_permanent_watch_kept_while_its_reindex_runs (RED without the running-job guard: the second change ended the watch). Signed-off-by: Martin Vogel --- src/daemon/application.c | 21 +++++ tests/test_daemon_application.c | 147 ++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/src/daemon/application.c b/src/daemon/application.c index a4e8f8fd1f..804614e699 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -4268,6 +4268,21 @@ static void application_watch_encode_index_args_locked(const cbm_daemon_applicat *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL; } +/* Under `daemon start` a watch outlives its last session, so delete_project + * from another session or the CLI can remove the project's DB while nothing + * owns the watch any more: daemon MCP sessions carry no watcher pointer and + * never unwatch it. Re-indexing such an orphan would resurrect the deleted + * project. A full reindex also unlinks the DB while it rebuilds it, so a + * missing DB means "deleted" only while no job for the project is running. + * Caller holds application->mutex. */ +static bool application_permanent_watch_orphaned_locked(cbm_daemon_application_t *application, + const cbm_daemon_application_watch_t *watch, + const char *project_key) { + return application->permanent && watch->subscribers == 0 && + !application_find_active_job_locked(application, project_key) && + !application_regular_db_exists(watch->project); +} + static cbm_daemon_application_job_t *application_background_index_admit( cbm_daemon_application_t *application, const char *project_name, const char *canonical_root, const char *project_key, yyjson_mut_doc *document, yyjson_mut_val *root, char **args, @@ -4281,6 +4296,12 @@ static cbm_daemon_application_job_t *application_background_index_admit( } cbm_daemon_application_watch_t *watch = require_live_watch ? application_find_watch_locked(application, project_name) : NULL; + if (watch && application_permanent_watch_orphaned_locked(application, watch, project_key)) { + /* End the orphan the way a session refresh ends a deleted project's + * watch; the callback below becomes a harmless skip. */ + application_remove_watch_locked(application, watch); + watch = NULL; + } bool watch_live = !require_live_watch || (watch && application_watch_root_matches(watch->root, canonical_root) && (watch->subscribers > 0 || application->permanent)); diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 67de9002fe..b4c0d23160 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -4687,6 +4687,151 @@ TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_ PASS(); } +/* A permanent daemon keeps a project watched after its last session closes. + * delete_project from another session or the CLI removes the project's DB but + * owns no watch, so the watch itself must notice: a later change must end the + * orphaned watch, never re-create the deleted index. */ +TEST(daemon_application_permanent_watch_ends_when_project_db_is_deleted) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 48); + if (fixture_ready) { + cbm_daemon_application_set_permanent(fixture.application, true); + /* Should a job be admitted, it completes at once: the verdict is + * read from the start counter, never from a timeout. */ + atomic_store(&fixture.fake.allow_completion, true); + } + if (fixture.session) { + fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session); + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + int watch_after_close = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool db_removed = fixture_ready && cbm_unlink(fixture.db_path) == 0; + int result = db_removed ? cbm_daemon_application_watcher_index(fixture.project, fixture.root, + fixture.application) + : -2; + int starts = fixture_ready ? atomic_load(&fixture.fake.starts) : -1; + int watch_after_index = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + + ASSERT_TRUE(fixture_ready); + ASSERT_EQ(watch_after_close, 1); + ASSERT_TRUE(db_removed); + /* A harmless skip: nothing admitted, the deleted index stays deleted. */ + ASSERT_EQ(result, 1); + ASSERT_EQ(starts, 0); + /* The orphaned physical watch is torn down. */ + ASSERT_EQ(watch_after_index, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + +/* The second watcher callback either joins the running job (its retained + * ownership is the job's second subscriber) or returns. Both states latch, so + * whichever happens decides the wait; the deadline only catches "neither". */ +static bool app_wait_for_watcher_join_or_return(cbm_daemon_application_t *application, + const char *project, atomic_bool *returned) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (cbm_daemon_application_job_subscribers(application, project) == 2 || + atomic_load(returned)) { + return true; + } + cbm_usleep(1000); + } + return false; +} + +/* A full reindex unlinks the project's DB while it rebuilds it, so a missing + * DB alone does not mean the project was deleted. While the permanent watch's + * own job runs, a second change in its root must join that job and keep the + * watch; once the job ends, both callbacks hand their ownership back. */ +TEST(daemon_application_permanent_watch_kept_while_its_reindex_runs) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 49); + if (fixture_ready) { + cbm_daemon_application_set_permanent(fixture.application, true); + } + if (fixture.session) { + fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session); + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + int watch_after_close = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + app_watcher_index_thread_t first = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = false, + .result = -1, + }; + atomic_init(&first.ready, false); + atomic_init(&first.proceed, true); + atomic_init(&first.done, false); + app_watcher_index_thread_t second = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = false, + .result = -1, + }; + atomic_init(&second.ready, false); + atomic_init(&second.proceed, true); + atomic_init(&second.done, false); + + /* The fake worker keeps the first job running until allow_completion. */ + cbm_thread_t first_thread; + bool first_started = fixture_ready && watch_after_close == 1 && + cbm_thread_create(&first_thread, 0, app_watcher_index_thread, &first) == 0; + bool job_running = first_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); + bool db_removed = job_running && cbm_unlink(fixture.db_path) == 0; + cbm_thread_t second_thread; + bool second_started = + db_removed && cbm_thread_create(&second_thread, 0, app_watcher_index_thread, &second) == 0; + bool settled = second_started && app_wait_for_watcher_join_or_return( + fixture.application, fixture.project, &second.done); + size_t subscribers_while_running = + fixture_ready ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project) + : 0; + bool second_returned_while_running = atomic_load(&second.done); + int watch_while_running = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + if (fixture_ready) { + atomic_store(&fixture.fake.allow_completion, true); + } + bool first_joined = first_started && app_wait_for_atomic_bool(&first.done, true) && + cbm_thread_join(&first_thread) == 0; + bool second_joined = second_started && app_wait_for_atomic_bool(&second.done, true) && + cbm_thread_join(&second_thread) == 0; + int starts = fixture_ready ? atomic_load(&fixture.fake.starts) : -1; + size_t subscribers_after = + fixture_ready ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project) + : SIZE_MAX; + int watch_after = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + + ASSERT_TRUE(fixture_ready); + ASSERT_EQ(watch_after_close, 1); + ASSERT_TRUE(job_running); + ASSERT_TRUE(db_removed); + ASSERT_TRUE(settled); + /* The second change joined the running job instead of ending the watch. */ + ASSERT_EQ(subscribers_while_running, 2); + ASSERT_FALSE(second_returned_while_running); + ASSERT_EQ(watch_while_running, 1); + ASSERT_TRUE(first_joined); + ASSERT_TRUE(second_joined); + ASSERT_EQ(first.result, 0); + ASSERT_EQ(second.result, 0); + ASSERT_EQ(starts, 1); + /* Each callback released the ownership it retained at admission. */ + ASSERT_EQ(subscribers_after, 0); + ASSERT_EQ(watch_after, 1); + ASSERT_TRUE(cleaned); + PASS(); +} + TEST(daemon_application_late_watcher_session_owns_active_watcher_job) { app_watch_race_fixture_t fixture; bool fixture_ready = app_watch_race_fixture_init(&fixture, 45); @@ -7793,6 +7938,8 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job); RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners); RUN_TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session); + RUN_TEST(daemon_application_permanent_watch_ends_when_project_db_is_deleted); + RUN_TEST(daemon_application_permanent_watch_kept_while_its_reindex_runs); RUN_TEST(daemon_application_late_watcher_session_owns_active_watcher_job); RUN_TEST(daemon_application_watch_roots_match_windows_separators_only); RUN_TEST(daemon_application_async_completion_disables_all_watches); From 8f36ea6c0b15a1060296718e93bb62aa45b5a2d7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 28 Oct 2026 00:49:16 +0100 Subject: [PATCH 53/97] test(pipeline): rename the near/far locals in the record accessor test Windows headers define near and far as empty macros, so the locals named near and far in the generated-accessor pipeline test did not compile on the Windows leg. Rename them to near_m and far_m; the node names stay. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 355ebbcf99..82755022cc 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -18059,14 +18059,14 @@ static int jgen_check(cbm_store_t *s, const char *project) { int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); int64_t norm = jgen_node(s, project, "norm", "Method", "Point.java", &gen_norm); - int64_t near = jgen_node(s, project, "near", "Method", "Caller.java", NULL); - int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); + int64_t near_m = jgen_node(s, project, "near", "Method", "Caller.java", NULL); + int64_t far_m = jgen_node(s, project, "far", "Method", "Caller.java", NULL); int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); int64_t stat = jgen_node(s, project, "stat", "Method", "Caller.java", NULL); int64_t refs = jgen_node(s, project, "refs", "Method", "Caller.java", NULL); JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0 && acc_nn > 0); - JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); + JGEN_EXPECT(near_m > 0 && far_m > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); /* The compiler's members are marked; the hand-written ones are not. */ JGEN_EXPECT(gen_x); JGEN_EXPECT(gen_y); @@ -18074,10 +18074,10 @@ static int jgen_check(cbm_store_t *s, const char *project) { JGEN_EXPECT(!gen_z); JGEN_EXPECT(!gen_norm); /* Bound by qualified name through the receiver's type ... */ - JGEN_EXPECT(jgen_calls(s, near, acc_y, NULL) == 1); + JGEN_EXPECT(jgen_calls(s, near_m, acc_y, NULL) == 1); /* ... and never by a short-name guess. */ - JGEN_EXPECT(jgen_calls(s, far, acc_y, NULL) == 0); - JGEN_EXPECT(jgen_calls(s, far, 0, "y") == 0); + JGEN_EXPECT(jgen_calls(s, far_m, acc_y, NULL) == 0); + JGEN_EXPECT(jgen_calls(s, far_m, 0, "y") == 0); /* Neither the accessor nor Spring's field `x`. */ JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); /* No import names a generated member, so nothing follows one onto it. */ From 631418683e005850a3bce49a81f2cf655ce53e1c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 28 Oct 2026 00:50:51 +0100 Subject: [PATCH 54/97] fix(daemon): keep the workspace-policy release under daemon start Under `daemon start` the session watch release kept every watch whose last subscriber left, so the daemon could keep watching after the session ended. The same release also runs when a session's root stops being allowed by workspace policy (an approval revoked, a boundary declared). That is not a disconnect: the session is still open and its root is refused, so keeping the watch there let a permanent daemon go on indexing a root the user had just withdrawn. application_release_session_watch_locked() now takes session_ending. The cancel and close paths pass true and keep the daemon-owned watch exactly as before; the workspace-policy branch of the watch refresh passes false and removes the watch in both modes, as it did before permanent watches. Test: daemon_application_permanent_workspace_release_still_ends_watch revokes the sensitive-root approval of a permanent session's root and sends one request; its synchronous refresh must end the watch (RED when the policy branch passes true). Signed-off-by: Martin Vogel --- src/daemon/application.c | 20 ++++-- tests/test_daemon_application.c | 117 ++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 7 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 804614e699..2383b03590 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -564,7 +564,8 @@ static bool application_watches_disabled_locked(cbm_daemon_application_t *applic return true; } -static void application_release_session_watch_locked(cbm_daemon_application_session_t *session) { +static void application_release_session_watch_locked(cbm_daemon_application_session_t *session, + bool session_ending) { application_watch_job_unsubscribe_session_locked(session, NULL); /* @@ -575,9 +576,14 @@ static void application_release_session_watch_locked(cbm_daemon_application_sess * * Non-permanent applications retain the historical session-scoped * lifecycle and remove the physical watch after the final subscriber. - * Project deletion/pruning and daemon shutdown remove persistent watches - * through their dedicated lifecycle paths. + * A release decided by workspace policy (session_ending false: the + * session's root is no longer allowed) is not a disconnect and removes + * the watch in both modes. Daemon shutdown and auto_watch=false remove + * persistent watches through their own paths; a deleted project's watch + * ends at its next watcher callback + * (application_permanent_watch_orphaned_locked). */ + bool daemon_keeps_watch = session_ending && session->application->permanent; while (session->watches) { application_watch_membership_t *membership = session->watches; cbm_daemon_application_watch_t *watch = membership->watch; @@ -585,7 +591,7 @@ static void application_release_session_watch_locked(cbm_daemon_application_sess cbm_free(CBM_MEM_CLASS_OTHER, membership); if (watch->subscribers > 0) { watch->subscribers--; - if (!session->application->permanent && watch->subscribers == 0) { + if (!daemon_keeps_watch && watch->subscribers == 0) { application_remove_watch_locked(session->application, watch); } } @@ -733,7 +739,7 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s return; } if (!application_session_workspace_allowed(session, "watch")) { - application_release_session_watch_locked(session); + application_release_session_watch_locked(session, false); return; } application_register_watch_locked(session, cbm_mcp_server_session_project(session->mcp), @@ -3661,7 +3667,7 @@ static void application_session_cancel(void *context, reap_update || (session->background_eligible && application->update_thread_started && application->update_owners == 0); application_index_watch_unsubscribe_locked(session, NULL); - application_release_session_watch_locked(session); + application_release_session_watch_locked(session, true); bool final_live_session = newly_cancelled; for (cbm_daemon_application_session_t *other = application->sessions; final_live_session && other; other = other->next) { @@ -3721,7 +3727,7 @@ static void application_session_close(void *context, reap_update || (session->background_eligible && application->update_thread_started && application->update_owners == 0); application_index_watch_unsubscribe_locked(session, NULL); - application_release_session_watch_locked(session); + application_release_session_watch_locked(session, true); cbm_mutex_unlock(&application->mutex); application_auto_index_cancel_join(application, join_auto_index); application_jobs_reap_completed(application); diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index b4c0d23160..6b8e91d9fd 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -2820,6 +2820,122 @@ TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls) { PASS(); } +/* Under `daemon start` a session that ENDS leaves its watch to the daemon, but + * a workspace-policy release is not a disconnect: once the session's root is + * no longer allowed (here its approval is revoked), the next request's + * refresh must still end the watch in a permanent generation too. */ +TEST(daemon_application_permanent_workspace_release_still_ends_watch) { + app_env_backup_t cache_environment; + app_env_backup_t home_environment; + bool environments_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR") && + app_env_backup_capture(&home_environment, "HOME"); + char sensitive_raw[APP_TEST_PATH_CAP]; + char cache_raw[APP_TEST_PATH_CAP]; + (void)snprintf(sensitive_raw, sizeof(sensitive_raw), + "%s/cbm-app-permanent-release-home-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache_raw, sizeof(cache_raw), "%s/cbm-app-permanent-release-cache-XXXXXX", + cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(sensitive_raw) != NULL && cbm_mkdtemp(cache_raw) != NULL; + char sensitive[APP_TEST_PATH_CAP] = {0}; + char cache[APP_TEST_PATH_CAP] = {0}; + bool canonical = dirs_ok && cbm_canonical_path(sensitive_raw, sensitive, sizeof(sensitive)) && + cbm_canonical_path(cache_raw, cache, sizeof(cache)); + bool environments_set = environments_saved && canonical && + cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 && + cbm_setenv("HOME", sensitive, 1) == 0; + cbm_config_t *stored_config = environments_set ? cbm_config_open(cache) : NULL; + bool config_ready = stored_config && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "false") == 0 && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "true") == 0; + char *project = canonical ? cbm_project_name_from_path(sensitive) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + if (project) { + (void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + } + bool db_ready = config_ready && project && app_test_create_empty_file(db_path); + /* The root is $HOME, so only an explicit sensitive approval allows it. */ + char grant_error[APP_TEST_PATH_CAP]; + bool approved = db_ready && cbm_workspace_grant_add(cache, sensitive, sensitive, true, + grant_error, sizeof(grant_error)); + char grant_path[APP_TEST_PATH_CAP] = {0}; + bool grant_path_ok = + approved && cbm_workspace_grant_path(cache, grant_path, sizeof(grant_path)); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); + cbm_daemon_application_config_t config = { + .watcher = watcher, + .config = stored_config, + }; + cbm_daemon_application_t *application = + grant_path_ok && watcher ? cbm_daemon_application_new(&config) : NULL; + if (application) { + cbm_daemon_application_set_permanent(application, true); + } + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + application ? app_test_open(&callbacks, 4036) : NULL; + + bool initialized = app_test_initialize_profile(&callbacks, session, sensitive, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + int watch_while_approved = watcher ? cbm_watcher_watch_count(watcher) : -1; + + /* Revoke the approval. Every request ends with a synchronous watch + * refresh, so one ping is the point where the policy is re-read. */ + bool revoked = initialized && cbm_unlink(grant_path) == 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool pinged = revoked && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":4136,\"method\":\"ping\"}", + &ping, &ping_length) && + app_test_request(&callbacks, session, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + free(ping); + int watch_after_revoke = watcher ? cbm_watcher_watch_count(watcher) : -1; + + if (session) { + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + if (watcher) { + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + } + cbm_store_close(store); + cbm_config_close(stored_config); + free(project); + (void)th_rmtree(sensitive); + (void)th_rmtree(cache); + bool cache_restored = app_env_backup_restore(&cache_environment); + bool home_restored = app_env_backup_restore(&home_environment); + + ASSERT_TRUE(environments_saved); + ASSERT_TRUE(dirs_ok); + ASSERT_TRUE(canonical); + ASSERT_TRUE(environments_set); + ASSERT_TRUE(config_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(approved); + ASSERT_TRUE(grant_path_ok); + ASSERT_TRUE(initialized); + ASSERT_EQ(watch_while_approved, 1); + ASSERT_TRUE(revoked); + ASSERT_TRUE(pinged); + /* A policy release, not a disconnect: the permanent watch ends as well. */ + ASSERT_EQ(watch_after_revoke, 0); + ASSERT_TRUE(stopped); + ASSERT_TRUE(cache_restored); + ASSERT_TRUE(home_restored); + PASS(); +} + TEST(daemon_application_programmatic_index_injects_resource_policy) { char *root = th_mktempdir("cbm_app_policy_root"); char *cache = th_mktempdir("cbm_app_policy_cache"); @@ -7904,6 +8020,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions); RUN_TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls); RUN_TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls); + RUN_TEST(daemon_application_permanent_workspace_release_still_ends_watch); RUN_TEST(daemon_application_ui_index_accepts_non_ascii_directory); RUN_TEST(daemon_application_index_args_compare_repo_path_separator_equivalently); RUN_TEST(daemon_application_async_index_returns_before_completion_and_status_polls); From 70d6d5017c741b57cd035b1ecc7927affb7908a2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 28 Oct 2026 00:51:48 +0100 Subject: [PATCH 55/97] docs(watcher): describe the watch lifetime under daemon start A daemon started with `daemon start` now keeps a project watched after its last owning MCP session closes, until the daemon stops, the project is deleted, its directory disappears, or auto_watch is set to false. README (the watcher paragraph and the index_repository row), the watcher_enabled row of docs/CONFIGURATION.md and the comment above the index_status watch provider still said every watch ends with its last session. Signed-off-by: Martin Vogel --- README.md | 4 ++-- docs/CONFIGURATION.md | 2 +- src/daemon/application.c | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8fd5c68a99..4162ae3710 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ codebase-memory-mcp config set auto_index true `auto_index` is off by default. When enabled, new projects are indexed automatically on first connection. The configurable file limit is `config set auto_index_limit 50000`. Previously indexed projects can be watched through `auto_watch` (on by default), subject to its global setting and session ownership. -Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched. +Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. Under a daemon started with `daemon start` a watch outlives its last owning session: the project stays watched and is re-indexed in the background when it changes, until that daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched. Watcher registration is controlled globally by `auto_watch` (default `true`). On the next refresh or registration request, `true` allows MCP sessions to register their root on connect and their successful `index_repository` targets; `false` removes all watches. CLI indexing and failed MCP indexes do not add watches. @@ -877,7 +877,7 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a | Tool | Description | |------|-------------| -| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected, subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). | +| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected (under `daemon start`, also after it disconnects), subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). | | `list_projects` | List indexed projects with paging; use `detail: "stats"` for node/edge counts and database sizes. | | `delete_project` | Remove a project and all its graph data. | | `index_status` | Check indexing status of a project. | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0981f6fc20..96a8237520 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -89,7 +89,7 @@ Current keys: | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | | `auto_watch` | `true` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. | | `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. | -| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; `index_status` reports the current state in its `watch` object. | +| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; under `daemon start` those watches outlive the session until the daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. `index_status` reports the current state in its `watch` object. | | `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | | `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. | diff --git a/src/daemon/application.c b/src/daemon/application.c index 2383b03590..eb9bf97f4d 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -824,7 +824,9 @@ static const char *application_watch_strategy_name(cbm_watcher_strategy_t strate /* index_status watch visibility (#2167). The physical watcher is the truth for * "watched"; session roots and successful explicit targets stay watched while - * at least one owning MCP session remains open. Takes the + * at least one owning MCP session remains open (under `daemon start`, also + * after the last one closes, until the daemon stops, the project is deleted, + * its root disappears, or auto_watch is switched off). Takes the * application mutex and the watcher lock one after the other, never nested. */ static void application_watch_status(void *context, const char *project, cbm_mcp_watch_status_t *out) { From 55844c9da9b969eb7e80b60b50c0402f2952ba0c Mon Sep 17 00:00:00 2001 From: Divakant Pandey Date: Wed, 28 Oct 2026 17:26:47 +0530 Subject: [PATCH 56/97] fix(cli): use managed binary path for agent configs when externally managed (#2722) Signed-off-by: Divakant Pandey --- src/cli/cli.c | 74 +++++++++++++++++++++++++++++++++++------------- src/cli/cli.h | 2 ++ tests/test_cli.c | 60 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 19 deletions(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index ba4ae520a4..79a08855c4 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -10979,7 +10979,20 @@ void cbm_cli_set_client_selection_for_testing(const char *spec) { /* Detect the running binary's path at runtime. The return value distinguishes * OS-reported identity (safe ownership evidence) from the ~/.local/bin fallback * used only as an install copy source. */ +#ifdef CBM_CLI_ENABLE_TEST_API +static const char *g_cli_self_path_override_for_test = NULL; +void cbm_cli_set_self_path_override_for_test(const char *self_path) { + g_cli_self_path_override_for_test = self_path; +} +#endif + static bool cbm_detect_self_path(char *buf, size_t buf_sz, const char *home) { +#ifdef CBM_CLI_ENABLE_TEST_API + if (g_cli_self_path_override_for_test) { + snprintf(buf, buf_sz, "%s", g_cli_self_path_override_for_test); + return true; + } +#endif buf[0] = '\0'; bool exact = false; #ifdef _WIN32 @@ -11241,6 +11254,7 @@ char *cbm_build_install_plan_json(const char *home, const char *binary_path) { typedef struct { const char *bin_target; + const char *config_binary; const char *bin_dir; const char *home; const char *shell_rc; @@ -11265,6 +11279,8 @@ static int cli_install_activate(void *opaque) { !activation->shell_rc) { return CLI_TRUE; } + const char *config_binary = + activation->config_binary ? activation->config_binary : activation->bin_target; char previous_managed_binary[CLI_BUF_1K] = {0}; bool previous_managed_binary_exact = cbm_detect_self_path( previous_managed_binary, sizeof(previous_managed_binary), activation->home); @@ -11313,9 +11329,13 @@ static int cli_install_activate(void *opaque) { * including same-binary and non-force installs. */ int agent_config_rc = CLI_OK; if (!activation->skip_config) { + const char *prev = previous_managed_binary_exact ? previous_managed_binary : NULL; + if (config_binary != activation->bin_target && + (!prev || strcmp(prev, config_binary) == 0)) { + prev = activation->bin_target; + } agent_config_rc = cbm_install_agent_configs_with_previous( - activation->home, activation->bin_target, - previous_managed_binary_exact ? previous_managed_binary : NULL, activation->force, + activation->home, config_binary, prev, activation->force, activation->dry_run); } if (agent_config_rc != CLI_OK) { @@ -11463,11 +11483,40 @@ int cbm_cmd_install(int argc, char **argv) { return CLI_TRUE; } + char self_path[CLI_BUF_1K] = {0}; + bool self_path_exact = cbm_detect_self_path(self_path, sizeof(self_path), home); + + /* #1566: a binary owned by mise/Homebrew/nix is not ours to relocate. Infer + * it, and let either flag override the inference in either direction. */ + bool externally_managed = + !force_binary && cli_binary_is_externally_managed(self_path, self_path_exact, bin_dir); + bool explicit_skip_binary = skip_binary; + if (externally_managed) { + skip_binary = true; + } + + /* NOT stat(): on Windows it goes through the ANSI code page, so an + * extended-length or non-ASCII target reports "absent" and a non-force + * install silently overwrites bytes the user asked to keep. */ + cbm_path_info_t target_status; + bool target_exists = cbm_path_info_utf8(bin_target, &target_status) == 0; + bool same_binary = cbm_same_file(self_path, bin_target); + + /* #2722: when a binary is managed externally (mise, brew, nix, etc.), + * do not write the non-existent default target into agent configurations. + * Use the running managed binary path instead. */ + const char *config_binary = bin_target; + if (externally_managed && self_path_exact && self_path[0]) { + config_binary = self_path; + } else if (skip_binary && !target_exists && self_path_exact && self_path[0]) { + config_binary = self_path; + } + /* --plan: emit the machine-readable install receipt and exit WITHOUT * mutating anything (no config writes, no index deletion, no network) so * an agent can inspect exactly what install would touch first (#388). */ if (plan) { - char *json = cbm_build_install_plan_json_options(home, bin_target, skip_config); + char *json = cbm_build_install_plan_json_options(home, config_binary, skip_config); if (!json) { (void)fprintf(stderr, "error: failed to build install plan\n"); return CLI_TRUE; @@ -11489,16 +11538,9 @@ int cbm_cmd_install(int argc, char **argv) { printf("codebase-memory-mcp install %s\n\n", CBM_VERSION); - char self_path[CLI_BUF_1K] = {0}; - bool self_path_exact = cbm_detect_self_path(self_path, sizeof(self_path), home); - - /* #1566: a binary owned by mise/Homebrew/nix is not ours to relocate. Infer - * it, and let either flag override the inference in either direction. */ - bool externally_managed = - !force_binary && cli_binary_is_externally_managed(self_path, self_path_exact, bin_dir); - if (externally_managed || skip_binary) { + if (externally_managed || explicit_skip_binary) { const char *manager = cli_external_manager_name(self_path); - if (skip_binary) { + if (explicit_skip_binary) { printf("Skipping binary placement (--skip-binary); configuring agents only.\n\n"); } else { printf("Binary is managed elsewhere%s%s:\n %s\n" @@ -11506,15 +11548,8 @@ int cbm_cmd_install(int argc, char **argv) { " (use --force-binary to install a copy into %s anyway)\n\n", manager ? " by " : "", manager ? manager : "", self_path, bin_dir); } - skip_binary = true; } - /* NOT stat(): on Windows it goes through the ANSI code page, so an - * extended-length or non-ASCII target reports "absent" and a non-force - * install silently overwrites bytes the user asked to keep. */ - cbm_path_info_t target_status; - bool target_exists = cbm_path_info_utf8(bin_target, &target_status) == 0; - bool same_binary = cbm_same_file(self_path, bin_target); bool do_copy = !skip_binary && !same_binary && (!target_exists || force); /* (#607) Default: preserve existing indexes. `--reset-indexes` opts into @@ -11681,6 +11716,7 @@ int cbm_cmd_install(int argc, char **argv) { #endif cli_install_activation_t activation = { .bin_target = bin_target, + .config_binary = config_binary, .bin_dir = bin_dir, .home = home, .shell_rc = shell_rc, diff --git a/src/cli/cli.h b/src/cli/cli.h index 4d32873ca6..da0e065176 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -250,6 +250,8 @@ void cbm_cli_set_client_selection_for_testing(const char *spec); * not recognisably foreign. Detection must be positive evidence — see the * implementation comment. */ const char *cbm_cli_external_manager_name_for_testing(const char *self_path); +/* #2722: override runtime binary self-path detection for testing. */ +void cbm_cli_set_self_path_override_for_test(const char *self_path); /* #1558: expose the config-key table so a test can prove a key is discoverable * (`config list`/`set` both walk it). */ size_t cbm_cli_config_key_count_for_testing(void); diff --git a/tests/test_cli.c b/tests/test_cli.c index 9853761cb7..e39f2f7a9f 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -18403,6 +18403,65 @@ TEST(cli_external_manager_detection_needs_positive_evidence_issue1566) { PASS(); } +/* #2722: when a binary is managed externally (e.g. mise, brew, nix), `install` + * skips placing a binary at ~/.local/bin, but must write the running managed + * binary path into agent configuration files instead of the non-existent + * default target. */ +TEST(cli_install_managed_binary_config_path_issue2722) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-install-managed-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + cbm_setenv("HOME", tmpdir, 1); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + + test_mark_claude_code_installed(tmpdir); + + const char *managed_path = + "/Users/x/.local/share/mise/installs/cbm/0.12.1/bin/codebase-memory-mcp"; + cbm_cli_set_self_path_override_for_test(managed_path); + + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + + char *argv[] = {"--clients=claude"}; + int rc = cli_test_cmd_install(1, argv); + + cbm_cli_set_activation_ops_for_test(NULL); + cbm_cli_set_self_path_override_for_test(NULL); + + char bin_target[512]; + snprintf(bin_target, sizeof(bin_target), "%s/.local/bin/codebase-memory-mcp", tmpdir); + struct stat target_st; + bool target_created = stat(bin_target, &target_st) == 0; + + char claude_json_path[512]; + snprintf(claude_json_path, sizeof(claude_json_path), "%s/.claude.json", tmpdir); + char *claude_json = read_test_file_alloc(claude_json_path); + bool has_managed_path = claude_json && strstr(claude_json, managed_path) != NULL; + bool has_target_path = + claude_json && strstr(claude_json, ".local/bin/codebase-memory-mcp") != NULL; + + free(claude_json); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 0); + ASSERT_FALSE(target_created); + ASSERT_TRUE(has_managed_path); + ASSERT_FALSE(has_target_path); + PASS(); +} + /* #1558: `install` configured EVERY detected client, so a user who wanted * Claude and Codex had to revert OpenCode and Cursor by hand — and the next * install silently recreated them. The selector restricts it. @@ -18820,6 +18879,7 @@ SUITE(cli) { RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); + RUN_TEST(cli_install_managed_binary_config_path_issue2722); RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558); RUN_TEST(cli_clients_selector_filters_registry_installs_issue1798); RUN_TEST(cli_update_accepts_retired_variant_flags_issue1544); From 113f24faf12bee65cf9678cdbc5ef23612678ede Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 28 Oct 2026 18:11:31 +0100 Subject: [PATCH 57/97] style(cli): clang-format the managed-binary agent-config call (#2722) Signed-off-by: Martin Vogel --- src/cli/cli.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index 79a08855c4..43fccf1bf3 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -11335,8 +11335,7 @@ static int cli_install_activate(void *opaque) { prev = activation->bin_target; } agent_config_rc = cbm_install_agent_configs_with_previous( - activation->home, config_binary, prev, activation->force, - activation->dry_run); + activation->home, config_binary, prev, activation->force, activation->dry_run); } if (agent_config_rc != CLI_OK) { cli_activation_transaction_finalize_committed_or_fail_stop( From dda8f8a953be727526c9d5a255744cfb151b9631 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 28 Oct 2026 21:49:08 +0100 Subject: [PATCH 58/97] 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 9db2b026ea..7c3734f6ba 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 51fd445f663fefabada1cb84b500039ece157479 Mon Sep 17 00:00:00 2001 From: knewstimek Date: Thu, 29 Oct 2026 07:05:52 +0900 Subject: [PATCH 59/97] fix(discover): select C++ for C-family include fragments Recognize explicit C-family code in .inc files before extraction while preserving BitBake recipe bodies and ObjectScript markers. Exercise disk and byte classifiers, callback/linkage extraction and fast/full indexing. Signed-off-by: knewstimek --- src/discover/discover.h | 5 +- src/discover/language.c | 162 +++++++++++++++++++++++++++++++++------- tests/test_extraction.c | 24 ++++++ tests/test_language.c | 50 +++++++++++++ tests/test_pipeline.c | 33 ++++++++ 5 files changed, 247 insertions(+), 27 deletions(-) diff --git a/src/discover/discover.h b/src/discover/discover.h index 85bc052945..a850df4e37 100644 --- a/src/discover/discover.h +++ b/src/discover/discover.h @@ -59,8 +59,9 @@ CBMLanguage cbm_disambiguate_frm(const char *path); CBMLanguage cbm_disambiguate_res(const char *path); /* Disambiguate .inc files by reading first 4KB of content. - * Returns CBM_LANG_OBJECTSCRIPT_ROUTINE if it looks like an ObjectScript - * include (a "ROUTINE " header), otherwise CBM_LANG_BITBAKE. + * Returns CBM_LANG_CPP for explicit C-family code, or + * CBM_LANG_OBJECTSCRIPT_ROUTINE for ObjectScript markers / a lone #define. + * BitBake recipe syntax and unknown content retain CBM_LANG_BITBAKE. * On read failure, defaults to CBM_LANG_BITBAKE. */ CBMLanguage cbm_disambiguate_inc(const char *path); diff --git a/src/discover/language.c b/src/discover/language.c index 7d967d7f3d..9c6dd041db 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -1472,38 +1472,150 @@ CBMLanguage cbm_disambiguate_cls(const char *path) { return lang_cls_text(buf); } -/* Disambiguate .inc files: shared by BitBake include fragments and - * InterSystems ObjectScript include (macro) files. ObjectScript .inc files are - * predominantly macro definitions ("#define NAME ..." / "#def1arg NAME ..."); - * some also carry a "ROUTINE " header. The macro-preprocessor directives - * are the strongest signal because that is the primary content of an .inc file, - * whereas BitBake uses '#' only for "# comment" lines (always '#' + space). - * We therefore match ObjectScript preprocessor directives ('#' immediately - * followed by 'def'/';'), which BitBake never produces. Defaults to BitBake on - * any doubt (preserves existing behaviour). */ +/* A complete leading token, followed by whitespace or punctuation, within a + * single line. In particular #include_extra is not #include. */ +static const char *lang_inc_word(const char *line, const char *end, const char *word) { + size_t n = strlen(word); + if ((size_t)(end - line) < n || strncmp(line, word, n) != 0 || + (line + n < end && (isalnum((unsigned char)line[n]) || line[n] == '_'))) { + return NULL; + } + line += n; + while (line < end && isspace((unsigned char)*line)) { + line++; + } + return line; +} + +/* Recipes can embed C in quoted strings or shell/Python tasks. Recognize the + * recipe's own syntax before inspecting those bodies as standalone C code. */ +static bool lang_inc_recipe_line(const char *line, const char *end) { + static const char *const words[] = {"require", "include", "inherit", "addtask", "python", NULL}; + for (int i = 0; words[i]; i++) { + const char *tail = lang_inc_word(line, end, words[i]); + if (tail && tail > line + strlen(words[i])) { + return true; + } + } + if ((size_t)(end - line) >= SLEN("do_") && strncmp(line, "do_", SLEN("do_")) == 0) { + return true; + } + const char *p = line; + while (p < end && (isalnum((unsigned char)*p) || *p == '_' || *p == ':' || *p == '[' || + *p == ']' || *p == '-' || *p == '.')) { + p++; + } + if (p == line) { + return false; + } + while (p < end && isspace((unsigned char)*p)) { + p++; + } + return p < end && (*p == '=' || ((*p == '?' || *p == '+' || *p == ':' || *p == '.') && + p + SKIP_ONE < end && p[SKIP_ONE] == '=')); +} + +/* Skip only leading whitespace and C comments, keeping block-comment state + * across lines. Recipe strings and bodies are handled before looking for C. */ +static const char *lang_inc_code(const char *line, const char *end, bool *block_comment) { + while (line < end) { + if (*block_comment) { + const char *close = strstr(line, "*/"); + if (!close || close >= end) { + return end; + } + line = close + SLEN("*/"); + *block_comment = false; + continue; + } + if (isspace((unsigned char)*line)) { + line++; + continue; + } + if ((size_t)(end - line) >= SLEN("/*") && strncmp(line, "/*", SLEN("/*")) == 0) { + *block_comment = true; + line += SLEN("/*"); + continue; + } + break; + } + return line; +} + +static bool lang_inc_c_code(const char *line, const char *end) { + static const char *const words[] = { + "extern", "static", "typedef", "namespace", "template", + "struct", "class", "int", "void", NULL, + }; + for (int i = 0; words[i]; i++) { + const char *tail = lang_inc_word(line, end, words[i]); + if (tail && tail < end && *tail != '=') { + return true; + } + } + const char *tail = lang_inc_word(line, end, "if"); + return tail && tail < end && *tail == '('; +} + +static bool lang_inc_directive(const char *line, const char *end, bool *c_family, + bool *objectscript_define) { + line += SKIP_ONE; /* '#' */ + while (line < end && isspace((unsigned char)*line)) { + line++; + } + if (lang_inc_word(line, end, "def1arg") || (line < end && *line == ';')) { + return true; /* Explicit ObjectScript directive. */ + } + if (lang_inc_word(line, end, "define")) { + *objectscript_define = true; + } + const char *tail = lang_inc_word(line, end, "include"); + if (tail && tail < end && (*tail == '"' || *tail == '<')) { + *c_family = true; + } + return false; +} + +/* .inc is shared by C/C++, BitBake and ObjectScript. Select C++ for explicit + * C-family code (including C fragments); do not infer an enclosing include + * context. A lone #define and unknown content retain their existing defaults. */ static CBMLanguage lang_inc_text(const char *buf) { + bool c_family = false; + bool objectscript_define = false; + bool block_comment = false; const char *line = buf; while (*line) { - /* ObjectScript include header: a line beginning "ROUTINE ". */ - if (strncmp(line, "ROUTINE ", SLEN("ROUTINE ")) == 0 && - isupper((unsigned char)line[SLEN("ROUTINE ")])) { - return CBM_LANG_OBJECTSCRIPT_ROUTINE; + const char *end = strchr(line, '\n'); + if (!end) { + end = line + strlen(line); + } + const char *code = lang_inc_code(line, end, &block_comment); + line = *end ? end + SKIP_ONE : end; + if (code == end) { + continue; } - /* ObjectScript macro directives — the primary content of .inc files. - * "#define"/"#def1arg" (macro defs) and "#;" (line comment). BitBake's - * only '#' use is "# comment" (hash + space), so these never collide. */ - if (strncmp(line, "#define", SLEN("#define")) == 0 || - strncmp(line, "#def1arg", SLEN("#def1arg")) == 0 || - strncmp(line, "#;", SLEN("#;")) == 0) { + const char *tail = lang_inc_word(code, end, "ROUTINE"); + if (tail && tail < end && isupper((unsigned char)*tail)) { return CBM_LANG_OBJECTSCRIPT_ROUTINE; } - const char *nl = strchr(line, '\n'); - if (!nl) { - break; + if (*code == '#') { + if (lang_inc_directive(code, end, &c_family, &objectscript_define)) { + return CBM_LANG_OBJECTSCRIPT_ROUTINE; + } + continue; } - line = nl + SKIP_ONE; + if ((size_t)(end - code) >= SLEN("//") && strncmp(code, "//", SLEN("//")) == 0) { + continue; + } + if (!c_family && lang_inc_recipe_line(code, end)) { + return CBM_LANG_BITBAKE; + } + c_family = c_family || lang_inc_c_code(code, end); + } + if (c_family) { + return CBM_LANG_CPP; } - return CBM_LANG_BITBAKE; + return objectscript_define ? CBM_LANG_OBJECTSCRIPT_ROUTINE : CBM_LANG_BITBAKE; } CBMLanguage cbm_disambiguate_inc(const char *path) { @@ -1664,7 +1776,7 @@ CBMLanguage cbm_language_classify_with(const cbm_userconfig_t *config, const cha /* ObjectScript UDL, Apex or a Visual Basic 6 class module */ lang = readable ? lang_cls_text(buf) : CBM_LANG_APEX; } else if (strcmp(ext, ".inc") == 0) { - /* BitBake or an ObjectScript include */ + /* C/C++, BitBake or an ObjectScript include */ lang = readable ? lang_inc_text(buf) : CBM_LANG_BITBAKE; } else if (cfc) { /* script or tag dialect */ diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 234ad1d9e1..35a8a9decb 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -250,6 +250,29 @@ TEST(extract_c_macros_issue375) { PASS(); } +TEST(extract_cpp_inc_linkage_and_callback) { + CBMFileResult *r = + extract("#define SAMPLE_CALLBACK(type) type\n" + "extern \"C\" __declspec(dllexport) int sample_export() { return 0; }\n" + "static SAMPLE_CALLBACK(int) sample_callback() { return sample_export(); }\n", + CBM_LANG_CPP, "sample", "runtime/sample.inc"); + ASSERT_NOT_NULL(r); + ASSERT_TRUE(has_def(r, "Function", "sample_export")); + ASSERT_TRUE(has_def(r, "Function", "sample_callback")); + ASSERT_FALSE(has_def_any(r, "extern")); + cbm_free_result(r); + + /* A statement include has no enclosing function in the file. Keep any + * syntax coverage diagnostic rather than inventing a callable owner. */ + r = extract("#ifdef ENABLE_SAMPLE\nif (sample_check()) return 1;\n#endif\n", CBM_LANG_CPP, + "sample", "runtime/guard.inc"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(has_def_any(r, "extern")); + ASSERT_FALSE(has_def(r, "Function", "if")); + cbm_free_result(r); + PASS(); +} + TEST(extract_cpp_macros_issue375) { CBMFileResult *r = extract("#define MAX(a, b) ((a) > (b) ? (a) : (b))\n" "#define PI 3.14159\n" @@ -13410,6 +13433,7 @@ SUITE(extraction) { RUN_TEST(traversal_stack_not_in_result_arena_issue2010); RUN_TEST(extract_c_macro_hidden_call_survives_preprocessed_pass_issue2010); RUN_TEST(extract_c_macros_issue375); + RUN_TEST(extract_cpp_inc_linkage_and_callback); RUN_TEST(extract_cpp_macros_issue375); RUN_TEST(extract_cpp_functionlike_macro_type_arg_no_false_parse_partial_issue1071); RUN_TEST(extract_cpp_real_in_body_error_still_flagged_issue1071); diff --git a/tests/test_language.c b/tests/test_language.c index 1ed72a0a21..8d7829a59e 100644 --- a/tests/test_language.c +++ b/tests/test_language.c @@ -1390,6 +1390,55 @@ static CBMLanguage classify_content(const char *name, const char *content, size_ typedef CBMLanguage (*path_probe_fn)(const char *path); +/* Exercise the shared classifier and both disk entry points. All sources are + * synthetic; the recipe bodies deliberately contain misleading C-like code. */ +TEST(lang_inc_c_family_and_existing_dialects) { + static const struct { + const char *content; + CBMLanguage expected; + } cases[] = { + {"#include \"sample.h\"\nextern \"C\" int sample_export() { return 0; }\n", CBM_LANG_CPP}, + {"extern \"C\" __declspec(dllexport) int sample_export() { return 0; }\n", CBM_LANG_CPP}, + {"#define SAMPLE_CALLBACK(T) T\nstatic SAMPLE_CALLBACK(int) sample() { return 0; }\n", + CBM_LANG_CPP}, + {"/* in-function fragment */\n#ifdef SAMPLE_ENABLED\nif (check()) return 1;\n#endif\n", + CBM_LANG_CPP}, + {"int sample(void) { return 0; }\n", CBM_LANG_CPP}, + {" \t# include \r\n", CBM_LANG_CPP}, + {"/* example:\nextern \"C\" int example();\n*/\nSUMMARY = \"sample\"\n", CBM_LANG_BITBAKE}, + {"// extern \"C\" example only\n", CBM_LANG_BITBAKE}, + {"#include_extra \"sample.h\"\n", CBM_LANG_BITBAKE}, + {"#define Value 1\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#ifndef SAMPLE\n#define SAMPLE 1\n#endif\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"ROUTINE Sample [Type=INC]\n#define Value 1\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#def1arg Value(%x) %x\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#; ObjectScript comment\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#include \n#def1arg Value(%x) %x\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"# recipe\nSUMMARY = \"sample\"\nrequire base.inc\n", CBM_LANG_BITBAKE}, + {"SRC_URI += \"file://sample.c\"\ndo_compile() {\nif (check) { return; }\n}\n", + CBM_LANG_BITBAKE}, + {"python do_generate() {\nif (ready):\n pass\n}\n", CBM_LANG_BITBAKE}, + {"do_compile() {\ncat <<'EOF'\n#include \nstatic int sample;\nEOF\n}\n", + CBM_LANG_BITBAKE}, + {"require base.inc\nextern \"C\" example in recipe text\n", CBM_LANG_BITBAKE}, + {"", CBM_LANG_BITBAKE}, + }; + char path[512]; + snprintf(path, sizeof(path), "%s/cbm_lang_inc_routing.inc", cbm_tmpdir()); + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + size_t len = strlen(cases[i].content); + ASSERT_EQ(classify_content("sample.inc", cases[i].content, len), cases[i].expected); + ASSERT_TRUE(write_exact(path, cases[i].content, len)); + CBMLanguage by_probe = cbm_disambiguate_inc(path); + CBMLanguage by_file = cbm_language_for_file("sample.inc", path); + remove(path); + ASSERT_EQ(by_probe, cases[i].expected); + ASSERT_EQ(by_file, cases[i].expected); + } + ASSERT_EQ(cbm_disambiguate_inc(NULL), CBM_LANG_BITBAKE); + PASS(); +} + TEST(lang_probe_bytes_follow_the_name) { ASSERT_EQ(cbm_language_probe_bytes("main.go"), 0); ASSERT_EQ(cbm_language_probe_bytes("Makefile"), 0); @@ -1805,6 +1854,7 @@ SUITE(language) { RUN_TEST(lang_ext_sosl); RUN_TEST(lang_all_have_names); + RUN_TEST(lang_inc_c_family_and_existing_dialects); RUN_TEST(lang_probe_bytes_follow_the_name); RUN_TEST(lang_classify_matches_every_content_rule); RUN_TEST(lang_classify_unreadable_content_keeps_the_name_default); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index c8c49a468e..852ba3ccf4 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -28535,7 +28535,40 @@ TEST(pipeline_calls_args_overlong_argument_stops_cleanly) { PASS(); } +/* Discovery must choose the C++ grammar before the pipeline extracts .inc + * symbols; a direct extractor-only test would miss that routing failure. */ +TEST(pipeline_inc_c_family_fragments) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "%s/cbm_inc_cpp_XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "runtime.inc", + "#define SAMPLE_CALLBACK(T) T\n" + "extern \"C\" int sample_export() { return 0; }\n" + "static SAMPLE_CALLBACK(int) sample_callback() { return sample_export(); }\n"); + write_temp_file(tmp, "main.cpp", "#include \"runtime.inc\"\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/graph.db", tmp); + const cbm_index_mode_t modes[] = {CBM_MODE_FAST, CBM_MODE_FULL}; + for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) { + cbm_pipeline_t *pipeline = cbm_pipeline_new(tmp, db_path, modes[i]); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + const char *project = cbm_pipeline_project_name(pipeline); + ASSERT_EQ(fixture_node_count(store, project, "runtime.inc", "sample_export", "Function"), + 1); + ASSERT_EQ(fixture_node_count(store, project, "runtime.inc", "sample_callback", "Function"), + 1); + ASSERT_EQ(named_node_count(store, project, "extern"), 0); + cbm_store_close(store); + cbm_pipeline_free(pipeline); + } + th_rmtree(tmp); + PASS(); +} SUITE(pipeline) { + RUN_TEST(pipeline_inc_c_family_fragments); RUN_TEST(pipeline_python_module_callers_sequential); RUN_TEST(pipeline_python_module_callers_parallel); RUN_TEST(pipeline_python_class_body_calls_sequential); From d68cd2e2af3bb848981cc2692c26bd387541a8cd Mon Sep 17 00:00:00 2001 From: Rodion Kazennov Date: Thu, 29 Oct 2026 02:02:02 +0000 Subject: [PATCH 60/97] fix(diagram): set an empty PlantUML operand id when there is none dg_puml_operand set *label up front but *id only on its success paths, so an opener without its closer (`Alice ->: x`, `[-> Alice: x`, `class [Unclosed`) returned with *id unwritten. dg_puml_relation and dg_puml_decl then read an uninitialized dg_str_t and passed it to dg_element, which crashed the index worker on some stacks. The operand now starts with an empty id, so those lines take the existing `!lid.n` / `!rid.n` exits and declarations make no element. Fixes #2818 Signed-off-by: Rodion Kazennov --- internal/cbm/diagram.c | 4 +++- tests/test_diagrams.c | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/cbm/diagram.c b/internal/cbm/diagram.c index cdd3785f4f..23d5bf5189 100644 --- a/internal/cbm/diagram.c +++ b/internal/cbm/diagram.c @@ -1225,9 +1225,11 @@ static bool dg_word_at(dg_str_t s, const char *word) { } /* An operand of a PlantUML line: `[Comp]`, `(use case)`, `:actor:`, `"text"`, - * an id, or `[*]`; its id in *id; the position after it (i when none). */ + * an id, or `[*]`; its id in *id; the position after it (i and an empty *id + * when none, as for the diagram edge in `A ->: x` or `[-> A: x`). */ static size_t dg_puml_operand(dg_str_t s, size_t i, dg_str_t *id, dg_str_t *label) { i = dg_skip_ws(s, i); + *id = dg_s(NULL, 0); *label = dg_s(NULL, 0); if (i + CBM_SZ_3 <= s.n && memcmp(s.p + i, "[*]", CBM_SZ_3) == 0) { *id = dg_s(s.p + i, CBM_SZ_3); diff --git a/tests/test_diagrams.c b/tests/test_diagrams.c index 574f27148a..cf1cc6e7bb 100644 --- a/tests/test_diagrams.c +++ b/tests/test_diagrams.c @@ -329,6 +329,36 @@ TEST(diagrams_plantuml) { PASS(); } +/* #2818: a sequence message to or from the diagram edge (`A ->: x`, `A <-: x`, + * `[-> A: x`) and a declaration with an unclosed bracket have no operand id; + * they used to read an uninitialized one and crash the index worker. */ +TEST(diagrams_plantuml_edge_messages) { + CBMFileResult *r = dg_file("@startuml\n" + "participant Alice\n" + "Alice ->: out\n" + "Alice <-: in\n" + "[-> Alice: from the edge\n" + "Alice -> Bob : hello\n" + "class [Unclosed\n" + "@enduml\n", + "docs/edge.puml"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Alice")); + ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Bob")); + ASSERT_EQ(r->diagram_arrows.count, 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Alice", "#diagram-1.Bob", "message", "hello"), 1); + int elements = 0; + for (int i = 0; i < r->defs.count; i++) { + const char *label = r->defs.items[i].label; + if (label && strcmp(label, "DiagramElement") == 0) { + elements++; + } + } + ASSERT_EQ(elements, 2); + cbm_free_result(r); + PASS(); +} + TEST(diagrams_dot) { CBMFileResult *r = dg_file("digraph G {\n" @@ -1101,6 +1131,7 @@ SUITE(diagrams) { RUN_TEST(diagrams_mermaid_kinds); RUN_TEST(diagrams_other_fences); RUN_TEST(diagrams_plantuml); + RUN_TEST(diagrams_plantuml_edge_messages); RUN_TEST(diagrams_dot); RUN_TEST(diagrams_d2); RUN_TEST(diagrams_drawio); From b94a7d7a32fea4f1ad7988e71450a1280d905a99 Mon Sep 17 00:00:00 2001 From: Divakant Pandey Date: Thu, 29 Oct 2026 19:39:31 +0530 Subject: [PATCH 61/97] 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 3db818c28b..280fbfed19 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 e3383be78f..18b243ab67 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 7f2fd1081d8a93616c36bffccff118d56b213541 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Thu, 29 Oct 2026 10:48:36 -0500 Subject: [PATCH 62/97] fix(hooks): tell subagents without MCP tools how to reach the graph via the CLI The SubagentStart hook matches every agent type, but agents defined with an explicit tool list (e.g. tools: Read, Grep, Glob, Bash) have no MCP tools, and SubagentStart input carries no tool list to detect that. Append one sentence to the SubagentStart context only (indexed and unindexed variants) pointing such agents at `codebase-memory-mcp cli --help`. Longest variant is ~1.1 KB, well inside the 2 KB context buffer. Closes #2837 Signed-off-by: Shane McCarron --- src/cli/hook_augment.c | 15 +++++++++++---- tests/test_cli.c | 17 +++++++++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index c161f1dbf8..d0120e89ca 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1707,6 +1707,13 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root scope = "Compaction"; } const char *tier = ha_active_tier(root, event); + /* SubagentStart carries no tool list, so agents defined without MCP tools + * (e.g. tools: Read, Grep, Glob, Bash) still get this; point them at the CLI. */ + const char *cli_hint = strcmp(event, "SubagentStart") == 0 + ? " If the codebase-memory MCP tools are not available to you, run " + "the same tools from the shell: `codebase-memory-mcp cli " + "--help` shows each tool's flags." + : ""; if (project) { char safe_project[HA_METADATA_CAP]; ha_sanitize_metadata(project, safe_project, sizeof(safe_project)); @@ -1723,8 +1730,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "reported missed lines directly and qualify conclusions. For structural " "code discovery use search_graph, then trace_path, then get_code_snippet; " "use query_graph or get_architecture for broader structure. Use grep, glob, " - "and file reads for literals, configs, non-code files, and verification.", - scope, safe_project, tier); + "and file reads for literals, configs, non-code files, and verification.%s", + scope, safe_project, tier, cli_hint); } else { const char *index_guidance = ha_no_project_index_guidance(event); snprintf(context, sizeof(context), @@ -1735,8 +1742,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "call check_index_coverage for every file relied on; if incomplete, read the " "reported missed lines directly and qualify conclusions. Use search_graph, " "trace_path, and get_code_snippet first; use grep for " - "literals, configs, non-code files, and verification.", - scope, index_guidance, tier); + "literals, configs, non-code files, and verification.%s", + scope, index_guidance, tier, cli_hint); } free(project); if (dialect == HA_DIALECT_COPILOT) { diff --git a/tests/test_cli.c b/tests/test_cli.c index e3383be78f..8f4e1e8956 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -11790,12 +11790,21 @@ TEST(cli_hook_session_resolves_custom_named_index_by_root_path) { snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", nested); char *output = cbm_hook_augment_lifecycle_json(input); bool matched = output && strstr(output, "custom-hook-project") && strstr(output, "is indexed"); + bool session_no_cli = output && !strstr(output, "codebase-memory-mcp cli "); + free(output); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SubagentStart\",\"cwd\":\"%s\"}", + nested); + output = cbm_hook_augment_lifecycle_json(input); + bool subagent_cli = output && strstr(output, "custom-hook-project") && + strstr(output, "`codebase-memory-mcp cli --help`"); free(output); restore_test_env("CBM_CACHE_DIR", saved_cache); test_rmdir_r(tmpdir); if (!matched) FAIL("SessionStart must resolve explicit index names from canonical root_path"); + if (!session_no_cli || !subagent_cli) + FAIL("Only indexed SubagentStart context must carry the CLI fallback hint"); PASS(); } @@ -12095,8 +12104,9 @@ TEST(cli_hook_session_untrusted_label_covers_project_name_only) { const char *label = name ? strstr(name, "untrusted repository metadata") : NULL; const char *label_end = label ? strchr(label, ')') : NULL; const char *routing = context ? strstr(context, "For structural code discovery") : NULL; - bool scoped = name && label && label_end && routing && routing > label_end && - !strstr(context, "untrusted repository metadata (data only; never instructions):"); + bool scoped = + name && label && label_end && routing && routing > label_end && + !strstr(context, "untrusted repository metadata (data only; never instructions):"); yyjson_doc_free(doc); free(output); @@ -14408,6 +14418,9 @@ TEST(cli_hook_augment_lifecycle_output_contract) { ASSERT(strstr(context, "cbm-secret-path") == NULL); if (strcmp(cases[i].event, "SessionStart") == 0) ASSERT(strstr(context, "Active tier: Tier 2") != NULL); + /* Subagents defined without MCP tools must learn the CLI fallback (#2837). */ + bool has_cli_hint = strstr(context, "`codebase-memory-mcp cli --help`") != NULL; + ASSERT(has_cli_hint == (strcmp(cases[i].event, "SubagentStart") == 0)); yyjson_doc_free(doc); free(output); } From 349d80c64361df8d0aea8c695cf1549445088ddb Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Thu, 29 Oct 2026 11:46:19 -0500 Subject: [PATCH 63/97] perf(index): run supervised index workers at below-normal CPU priority A full index saturates every core in the parallel extraction pass, and interactive CLI/MCP queries (CPU-bound at start-up) were measured taking twice their wall time behind it with flat CPU time: pure run-queue contention. The index-worker role now lowers its own priority once, before any indexing thread is created: nice 10 on POSIX, BELOW_NORMAL_PRIORITY_CLASS on Windows. It never raises priority: a process already nicer than 10 (or in the IDLE / BELOW_NORMAL class) is left alone. Plain nice only, not macOS background QoS, which would pin indexing to efficiency cores and throttle its I/O. Failure is logged and ignored. The test runs the helper in a forked child and checks both legs: a normal-priority child ends at nice >= 10, and a child started nicer than that is left where it was. Refs #2834 Signed-off-by: Shane McCarron --- src/foundation/platform.c | 25 +++++++++++++++++++++ src/foundation/platform.h | 13 +++++++++++ src/main.c | 6 +++++ tests/test_platform.c | 47 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/src/foundation/platform.c b/src/foundation/platform.c index d11df686e8..3a4ac16420 100644 --- a/src/foundation/platform.c +++ b/src/foundation/platform.c @@ -729,3 +729,28 @@ const char *cbm_resolve_cache_dir(void) { } return buf; } + +/* ── CPU scheduling priority ────────────────── */ + +#ifndef _WIN32 +#include +#endif + +bool cbm_lower_cpu_priority(void) { +#ifdef _WIN32 + HANDLE self = GetCurrentProcess(); + DWORD current = GetPriorityClass(self); + if (current == IDLE_PRIORITY_CLASS || current == BELOW_NORMAL_PRIORITY_CLASS) { + return true; /* already at least this low: never raise priority */ + } + return SetPriorityClass(self, BELOW_NORMAL_PRIORITY_CLASS) != 0; +#else + /* getpriority can legitimately return -1, so errno is the only error signal. */ + errno = 0; + int current = getpriority(PRIO_PROCESS, 0); + if (errno == 0 && current >= CBM_LOW_CPU_PRIORITY_NICE) { + return true; /* already at least this nice: never raise priority */ + } + return setpriority(PRIO_PROCESS, 0, CBM_LOW_CPU_PRIORITY_NICE) == 0; +#endif +} diff --git a/src/foundation/platform.h b/src/foundation/platform.h index 40b020222c..8eb4627786 100644 --- a/src/foundation/platform.h +++ b/src/foundation/platform.h @@ -215,4 +215,17 @@ int64_t cbm_file_size(const char *path); * On POSIX, this is a no-op. Returns the input pointer. */ char *cbm_normalize_path_sep(char *path); +/* ── CPU scheduling priority ───────────────────────────────────── */ + +enum { CBM_LOW_CPU_PRIORITY_NICE = 10 }; + +/* Lower this process's CPU scheduling priority so background work yields the + * cores to interactive processes. POSIX: nice CBM_LOW_CPU_PRIORITY_NICE (never + * raises an already-nicer process). Plain nice only, deliberately not macOS + * background QoS: that pins work to efficiency cores and throttles I/O. + * Windows: BELOW_NORMAL_PRIORITY_CLASS. On Linux nice is per thread, so call + * this before creating threads; later threads inherit it. + * Returns false when the priority could not be changed. */ +bool cbm_lower_cpu_priority(void); + #endif /* CBM_PLATFORM_H */ diff --git a/src/main.c b/src/main.c index c2f22ada9d..3f5e80e22c 100644 --- a/src/main.c +++ b/src/main.c @@ -902,6 +902,12 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo * own here, and only here (cbm_sqlite_dedicated_heap). */ cbm_sqlite_dedicated_heap(true); cbm_memev_process_role("worker"); + /* A full index saturates every core; run it below normal priority so + * interactive CLI/MCP queries are not starved. Before any indexing + * thread exists, so they all inherit it. Failure is harmless. */ + if (!cbm_lower_cpu_priority()) { + cbm_log_info("index.worker.priority", "lowered", "false"); + } } cbm_index_set_worker_role_options(index_worker, response_out, worker_single_thread, worker_marker, worker_quarantine, diff --git a/tests/test_platform.c b/tests/test_platform.c index 2c383c025d..d83f90dab6 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -16,6 +16,9 @@ #include #ifndef _WIN32 #include /* chmod for the symlink-policy mode legs */ +#include +#include /* getpriority for the worker-priority leg */ +#include #endif #ifdef __linux__ @@ -1442,6 +1445,49 @@ TEST(regex_estimate_counts_brackets_and_escapes_literally) { PASS(); } +/* An index worker lowers its own priority (#2834). A non-root process cannot + * raise it back, so the helper runs in a forked child that reports its nice. */ +TEST(platform_lower_cpu_priority_nices_the_process) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX nice contract"); +#else + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + pid_t child = fork(); + if (child == 0) { + (void)close(fds[0]); + /* report: lowered ok, nice after; then, from a nicer start, ok, target, nice after */ + int report[5] = {cbm_lower_cpu_priority() ? 1 : 0, 0, 0, 0, 0}; + errno = 0; + report[1] = getpriority(PRIO_PROCESS, 0); + if (errno != 0) { + report[0] = 0; + } + /* Never-raise leg: start nicer than the helper's level; it must leave it. */ + report[3] = report[1] >= 15 ? 19 : 15; + if (setpriority(PRIO_PROCESS, 0, report[3]) == 0) { + report[2] = cbm_lower_cpu_priority() ? 1 : 0; + errno = 0; + report[4] = getpriority(PRIO_PROCESS, 0); + } + _exit(write(fds[1], report, sizeof(report)) == (ssize_t)sizeof(report) ? 0 : 1); + } + ASSERT_TRUE(child > 0); + (void)close(fds[1]); + int report[5] = {0, 0, 0, 0, 0}; + ssize_t got = read(fds[0], report, sizeof(report)); + (void)close(fds[0]); + int status = 0; + (void)waitpid(child, &status, 0); + ASSERT_EQ(got, (ssize_t)sizeof(report)); + ASSERT_EQ(report[0], 1); + ASSERT_TRUE(report[1] >= CBM_LOW_CPU_PRIORITY_NICE); + ASSERT_EQ(report[2], 1); + ASSERT_EQ(report[4], report[3]); + PASS(); +#endif +} + SUITE(platform) { RUN_TEST(platform_file_apis_survive_max_path_overflow); RUN_TEST(platform_mkdir_p_follows_own_symlink_only_when_opted_in); @@ -1477,6 +1523,7 @@ SUITE(platform) { RUN_TEST(platform_env_long_refuses_what_it_cannot_read); RUN_TEST(platform_env_dup_honours_the_settings_resolver); RUN_TEST(platform_system_info); + RUN_TEST(platform_lower_cpu_priority_nices_the_process); RUN_TEST(regex_compile_refuses_oversized_expansion); RUN_TEST(regex_compile_accepts_expansion_under_budget); RUN_TEST(regex_compile_refuses_overlong_pattern); From 5a666f2fa3431a3389027a3428e37816f7208f77 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Thu, 29 Oct 2026 11:46:23 -0500 Subject: [PATCH 64/97] fix(mcp): match search_code file_pattern with a directory against the repo-relative path search_code matched file_pattern against the file basename only, so any pattern containing a directory (src/cli/*.c, apps/web/src/**/*.ts) silently returned 0 results, while search_graph's file_pattern matches the full repo-relative path. A file_pattern containing '/' is now matched in C against the repo-relative path of every indexed file, on all platforms, with search_graph-equivalent semantics: '*' and '**' match across '/', '**/' also matches zero directories, '?' matches one character, and a pattern without wildcards is a substring match. Patterns without '/' keep the existing basename behaviour. On Windows such patterns are scanned in process from the already-filtered list instead of the PowerShell -like post-filter. A search with a '/' pattern that returns nothing now carries a hint explaining whole-path matching and pointing at path_filter, and the schema describes both parameters. Refs #2836 Signed-off-by: Shane McCarron --- src/mcp/mcp.c | 70 +++++++++++++++++++++++++++++++++++++--- tests/test_mcp.c | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 4 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 1fc5f45888..e80cc2377a 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -729,7 +729,9 @@ static const tool_def_t TOOLS[] = { {"search_code", "Graph-ranked text search: compact symbols, full bounded source, or file paths.", "{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"project\":{\"type\":" - "\"string\"},\"file_pattern\":{\"type\":\"string\"},\"path_filter\":{\"type\":\"string\"}," + "\"string\"},\"file_pattern\":{\"type\":\"string\",\"description\":\"Glob on file name; " + "with '/', on repo-relative path (** = any dirs).\"}," + "\"path_filter\":{\"type\":\"string\",\"description\":\"Regex on repo-relative path.\"}," "\"mode\":{\"type\":\"string\"," "\"enum\":[\"compact\",\"full\",\"files\"],\"default\":\"compact\"}," "\"context\":{\"type\":\"integer\",\"default\":0,\"minimum\":0,\"maximum\":200}," @@ -14716,6 +14718,7 @@ typedef struct { uint64_t enrich_ms; uint64_t elapsed_ms; bool include_phase_timings; + bool hint_path_filter; /* a search with a '/' file_pattern returned no matches */ } search_metrics_t; #ifdef CBM_ENABLE_TEST_SEAMS @@ -14810,6 +14813,47 @@ static int search_result_cmp(const void *a, const void *b) { return 0; } +#define SEARCH_CODE_PATH_HINT \ + "no results; a file_pattern with '/' must match the whole repo-relative path (e.g. " \ + "src/**/*.c); to scope by folder you can also use path_filter (regex on the " \ + "repo-relative path, e.g. ^src/cli/)" + +/* A file_pattern containing '/' is matched against the whole repo-relative path, like + * search_graph's file_pattern: '*' and '**' match any run of characters including '/', a + * '**' followed by '/' also matches zero directories, '?' matches one character, and a + * pattern without wildcards is a substring match. Portable (no fnmatch on Windows) and + * linear: every '*' is equivalent, so one backtrack point suffices. */ +static bool search_code_path_glob_match(const char *pat, const char *path) { + if (!strpbrk(pat, "*?")) { + return strstr(path, pat) != NULL; + } + const char *star_pat = NULL; + const char *star_path = NULL; + for (;;) { + if (*pat == '*') { + const char *run = pat; + while (*pat == '*') { + pat++; + } + if (pat - run > SKIP_ONE && *pat == '/') { + pat++; + } + star_pat = pat; + star_path = path; + } else if (*path && (*pat == '?' || *pat == *path)) { + pat++; + path++; + } else if (!*pat && !*path) { + return true; + } else if (star_pat && *star_path) { + pat = star_pat; + path = ++star_path; + } else { + return false; + } + } +} + /* Moving an arbitrary file_pattern ahead of Select-String is not generally results-preserving: * the current Windows path applies PowerShell -like to the full MatchInfo.Path, while POSIX * delegates glob semantics to grep --include. Restrict the Windows optimization to plain suffix @@ -15432,6 +15476,9 @@ static char *assemble_search_output_toon(search_result_t *sr, int sr_count, grep "(not as alternation). Pass regex=true for 'foo|bar' to mean " "'foo OR bar'."); } + if (metrics->hint_path_filter) { + cbm_tree_scalar_str(&sb, "hint", SEARCH_CODE_PATH_HINT); + } if (metrics->elapsed_ms >= SEARCH_SLOW_MS) { cbm_tree_scalar_str(&sb, "warning_slow", "search was slow; narrow file_pattern/path_filter or use a more " @@ -15671,6 +15718,9 @@ static char *assemble_search_output( if (yyjson_mut_arr_size(warnings) > 0) { yyjson_mut_obj_add_val(doc, root_obj, "warnings", warnings); } + if (metrics->hint_path_filter) { + yyjson_mut_obj_add_str(doc, root_obj, "hint", SEARCH_CODE_PATH_HINT); + } char *json = yy_doc_to_str(doc); if (json) { @@ -16448,6 +16498,17 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co continue; } } + /* A file_pattern with '/' is matched here on every platform; the basename + * filters below (and the Windows prefilter, which rejects '/') skip it. */ + bool path_glob = file_pattern && strchr(file_pattern, '/'); + if (path_glob) { +#ifdef _WIN32 + cbm_normalize_path_sep(indexed_files[fi]); +#endif + if (!search_code_path_glob_match(file_pattern, indexed_files[fi])) { + continue; + } + } #ifndef _WIN32 /* GNU grep's --include is unavailable in BusyBox grep. Filter the * canonical list before xargs instead, preserving grep's basename @@ -16455,7 +16516,7 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co * on GNU userland. Windows keeps its PowerShell -like filter. */ const char *basename = strrchr(indexed_files[fi], '/'); basename = basename ? basename + SKIP_ONE : indexed_files[fi]; - if (file_pattern && fnmatch(file_pattern, basename, 0) != 0) { + if (file_pattern && !path_glob && fnmatch(file_pattern, basename, 0) != 0) { continue; } #endif @@ -17473,8 +17534,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { bool scan_in_process = scoped && !srv->search_scan_command_override; #ifdef _WIN32 scan_in_process = - scan_in_process && - (!file_pattern || cbm_search_code_file_pattern_can_prefilter(file_pattern)); + scan_in_process && (!file_pattern || strchr(file_pattern, '/') || + cbm_search_code_file_pattern_can_prefilter(file_pattern)); #endif mcp_scan_cause_t scan_cause; if (scan_in_process) { @@ -17646,6 +17707,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { directory_output = directory_limit; } bool warn_literal_pipe = pat_has_pipe && !use_regex; + metrics.hint_path_filter = file_pattern && strchr(file_pattern, '/') && gm_count == 0; /* Every render below re-checks each result's file against the root; one * containment for the request keeps that to one resolution per file. */ root_containment_t request_containment; diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 671cef3711..e02cd8605c 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -11670,6 +11670,88 @@ TEST(search_code_scoped_file_pattern_is_busybox_portable) { #endif } +/* issue #2836: a file_pattern with '/' matched only the basename, so every + * directory-scoped glob silently returned 0 results. */ +TEST(search_code_file_pattern_matches_repo_relative_path_issue2836) { + char tmp[CBM_SZ_512]; + snprintf(tmp, sizeof(tmp), "%s/cbm-search-fp-path-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char src_dir[CBM_SZ_1K]; + char cli_dir[CBM_SZ_1K]; + char main_path[CBM_SZ_1K]; + char watcher_path[CBM_SZ_1K]; + snprintf(src_dir, sizeof(src_dir), "%s/src", tmp); + snprintf(cli_dir, sizeof(cli_dir), "%s/src/cli", tmp); + snprintf(main_path, sizeof(main_path), "%s/src/main.c", tmp); + snprintf(watcher_path, sizeof(watcher_path), "%s/src/cli/watcher.c", tmp); + ASSERT_EQ(cbm_mkdir(src_dir), 0); + ASSERT_EQ(cbm_mkdir(cli_dir), 0); + ASSERT_EQ(th_write_file(main_path, "int FP_PATH_NEEDLE = 1;\n"), 0); + ASSERT_EQ(th_write_file(watcher_path, "int FP_PATH_NEEDLE = 2;\n"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "search-fp-path"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, tmp), CBM_STORE_OK); + cbm_node_t main_file = {.project = project, + .label = "File", + .name = "main.c", + .qualified_name = "search-fp-path.src.main.c", + .file_path = "src/main.c"}; + cbm_node_t watcher_file = {.project = project, + .label = "File", + .name = "watcher.c", + .qualified_name = "search-fp-path.src.cli.watcher.c", + .file_path = "src/cli/watcher.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &main_file), 0); + ASSERT_GT(cbm_store_upsert_node(store, &watcher_file), 0); + + static const struct { + const char *file_pattern; + const char *expect; + bool hint; + } cases[] = { + {"src/cli/*.c", "\"total_grep_matches\":1", false}, + {"src/**/*.c", "\"total_grep_matches\":2", false}, /* ** spans zero dirs */ + {"**/cli/*.c", "\"total_grep_matches\":1", false}, + {"src/cli/", "\"total_grep_matches\":1", false}, /* no wildcard: substring */ + {"watcher.c", "\"total_grep_matches\":1", false}, /* basename unchanged */ + {"*.c", "\"total_grep_matches\":2", false}, + {"lib/*.c", "\"total_grep_matches\":0", true}, + }; + bool ok = true; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), + "{\"pattern\":\"FP_PATH_NEEDLE\",\"project\":\"search-fp-path\"," + "\"file_pattern\":\"%s\",\"format\":\"json\"}", + cases[i].file_pattern); + char *response = cbm_mcp_handle_tool(srv, "search_code", args); + char *inner = extract_text_content(response); + bool case_ok = inner && strstr(inner, cases[i].expect) && + (strstr(inner, "use path_filter") != NULL) == cases[i].hint; + if (!case_ok) { + fprintf(stderr, "file_pattern %s: %s\n", cases[i].file_pattern, + inner ? inner : "(null)"); + ok = false; + } + free(inner); + free(response); + } + + cbm_mcp_server_free(srv); + cbm_unlink(watcher_path); + cbm_unlink(main_path); + cbm_rmdir(cli_dir); + cbm_rmdir(src_dir); + cbm_rmdir(tmp); + ASSERT_TRUE(ok); + PASS(); +} + TEST(search_code_recursive_fallback_propagates_discovery_failures) { #ifdef _WIN32 SKIP_PLATFORM("POSIX find/sort/xargs fallback contract"); @@ -26322,6 +26404,7 @@ SUITE(mcp) { RUN_TEST(search_code_fails_closed_when_complete_scan_is_impossible); RUN_TEST(search_code_scoped_scan_uses_canonical_file_nodes); RUN_TEST(search_code_scoped_file_pattern_is_busybox_portable); + RUN_TEST(search_code_file_pattern_matches_repo_relative_path_issue2836); RUN_TEST(search_code_recursive_fallback_propagates_discovery_failures); RUN_TEST(search_code_default_budget_limits_raw_rows_before_graph_results); RUN_TEST(search_code_raw_and_directory_remainders_are_independently_pageable); From ab8f68a169190f00a7010f339b24a69e3faa9446 Mon Sep 17 00:00:00 2001 From: pcristin Date: Thu, 29 Oct 2026 22:21:48 +0000 Subject: [PATCH 65/97] fix(mcp): expose recursion metadata in trace_path Report stored recursion signals and the static self-call omission policy alongside graph reachability totals. Preserve the metadata across cursor pages and output-budget fallback responses, and document unknown legacy metadata without changing CALLS indexing or traversal counts. Fixes #2816 Signed-off-by: pcristin --- README.md | 10 +++ src/mcp/mcp.c | 71 ++++++++++++++++ tests/test_mcp.c | 209 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 289 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c9b4a46e58..e22f5fb126 100644 --- a/README.md +++ b/README.md @@ -934,6 +934,16 @@ mode. `index_status` keeps describing the published graph and its freshness. `manage_adr` query modes (`get` and `sections`) use the server's cached query store so they can proceed while a same-project reindex is running. If another process publishes a replacement store during reindexing, they can return the pre-publication ADR until idle eviction refreshes that cache. Updates remain serialized through the project mutation guard. +`trace_path` exposes the start nodes' stored `self_recursive` (direct recursion) +and `recursive` (direct recursion or a call-graph cycle) signals in both tree and +JSON output. Across multiple start nodes, a flag is `true` if any records true, +`false` if all record false, and `null` otherwise (including missing legacy +metadata). These are static-analysis signals, not counts of resolved self-calls. +Static `CALLS` indexing omits direct self-call edges; traces that include `CALLS` state +`self_call_policy: omitted`. Caller/callee totals still count graph-reachable +nodes, so a recursive function can have zero callees. Recursion metadata remains +visible on continuation pages and when an output budget prevents rows from fitting. + ## Graph Data Model ### Node Labels diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 97ce062348..a03a87cd73 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -10137,6 +10137,72 @@ static void trace_call_coverage(cbm_store_t *store, const char *project, cbm_ht_free(match.targets); } +enum { TRACE_RECURSION_FLAG_COUNT = 2 }; +typedef enum { + TRACE_RECURSION_UNKNOWN = -1, + TRACE_RECURSION_FALSE, + TRACE_RECURSION_TRUE, +} trace_recursion_flag_t; +static const char *const trace_recursion_keys[TRACE_RECURSION_FLAG_COUNT] = {"self_recursive", + "recursive"}; +typedef struct { + /* -1: unknown, 0: all roots record false, 1: at least one records true. */ + trace_recursion_flag_t flags[TRACE_RECURSION_FLAG_COUNT]; +} trace_recursion_t; + +static trace_recursion_t trace_recursion_metadata(const cbm_node_t *nodes, int node_count) { + trace_recursion_t metadata = {0}; + /* Use the same roots as bfs_union_same_name, rather than borrowing a + * callee's flag or selecting just one definition/stub. These are stored + * analysis signals, not evidence that a resolved self-call was dropped. */ + for (int i = 0; i < node_count; i++) { + const char *properties = nodes[i].properties_json; + yyjson_doc *doc = properties ? yyjson_read(properties, strlen(properties), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + for (int j = 0; j < TRACE_RECURSION_FLAG_COUNT; j++) { + yyjson_val *value = yyjson_obj_get(root, trace_recursion_keys[j]); + if (yyjson_is_bool(value) && yyjson_get_bool(value)) { + metadata.flags[j] = TRACE_RECURSION_TRUE; + } else if (!yyjson_is_bool(value) && metadata.flags[j] != TRACE_RECURSION_TRUE) { + metadata.flags[j] = TRACE_RECURSION_UNKNOWN; + } + } + yyjson_doc_free(doc); + } + return metadata; +} + +static void trace_recursion_tree(cbm_sb_t *sb, const trace_recursion_t *metadata, + bool includes_calls) { + for (int i = 0; i < TRACE_RECURSION_FLAG_COUNT; i++) { + if (metadata->flags[i] == TRACE_RECURSION_UNKNOWN) { + cbm_sb_append(sb, trace_recursion_keys[i]); + cbm_sb_append(sb, ": null\n"); + } else { + cbm_tree_scalar_bool(sb, trace_recursion_keys[i], + metadata->flags[i] == TRACE_RECURSION_TRUE); + } + } + if (includes_calls) { + cbm_tree_scalar_str(sb, "self_call_policy", "omitted"); + } +} + +static void trace_recursion_json(yyjson_mut_doc *doc, yyjson_mut_val *root, + const trace_recursion_t *metadata, bool includes_calls) { + for (int i = 0; i < TRACE_RECURSION_FLAG_COUNT; i++) { + if (metadata->flags[i] == TRACE_RECURSION_UNKNOWN) { + yyjson_mut_obj_add_null(doc, root, trace_recursion_keys[i]); + } else { + yyjson_mut_obj_add_bool(doc, root, trace_recursion_keys[i], + metadata->flags[i] == TRACE_RECURSION_TRUE); + } + } + if (includes_calls) { + yyjson_mut_obj_add_str(doc, root, "self_call_policy", "omitted"); + } +} + static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { char *func_name = cbm_mcp_get_string_arg(args, "function_name"); char *project = get_project_arg(args); @@ -10491,6 +10557,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { .do_inbound = do_inbound}; trace_call_coverage(store, project, &coverage_scope, trace_includes_calls, &unresolved_out, &unresolved_in); + trace_recursion_t recursion = trace_recursion_metadata(nodes, node_count); const char *out_relation = unresolved_out ? "unknown" : (tr_out.truncated ? "gte" : "eq"); const char *in_relation = unresolved_in ? "unknown" : (tr_in.truncated ? "gte" : "eq"); @@ -10574,6 +10641,7 @@ render_trace_output:; if (mode) { cbm_tree_scalar_str(&sb, "mode", mode); } + trace_recursion_tree(&sb, &recursion, trace_includes_calls); /* Grouped tree is THE default; risk_labels/data_flow keep the flat * table (extra columns) in the same tree syntax. */ bool render_risk = risk_labels && emit_optional_fields; @@ -10657,6 +10725,7 @@ render_trace_output:; if (mode) { yyjson_mut_obj_add_str(doc, root, "mode", mode); } + trace_recursion_json(doc, root, &recursion, trace_includes_calls); if (do_outbound) { yyjson_mut_obj_add_int(doc, root, "callees_total", out_total); yyjson_mut_obj_add_str(doc, root, "callees_total_relation", out_relation); @@ -10724,6 +10793,7 @@ render_trace_output:; if (!trace_legacy_json) { cbm_sb_t floor; cbm_sb_init(&floor); + trace_recursion_tree(&floor, &recursion, trace_includes_calls); if (do_outbound) { cbm_tree_scalar_int(&floor, "callees_total", out_total); cbm_tree_scalar_str(&floor, "callees_total_relation", out_relation); @@ -10752,6 +10822,7 @@ render_trace_output:; yyjson_mut_doc *floor_doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *floor = yyjson_mut_obj(floor_doc); yyjson_mut_doc_set_root(floor_doc, floor); + trace_recursion_json(floor_doc, floor, &recursion, trace_includes_calls); if (do_outbound) { yyjson_mut_obj_add_int(floor_doc, floor, "callees_total", out_total); yyjson_mut_obj_add_str(floor_doc, floor, "callees_total_relation", diff --git a/tests/test_mcp.c b/tests/test_mcp.c index c6a389d21d..3f7a67c45c 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -7403,6 +7403,200 @@ TEST(tool_index_status_includes_git_metadata) { * TOOL HANDLERS WITH DATA * ══════════════════════════════════════════════════════════════════ */ +/* #2816: stored recursion must remain visible even when the CALLS graph has + * no self-edges. Removing metadata emission must fail these assertions. */ +TEST(tool_trace_path_recursion_metadata_issue2816) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "recursion-proj"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/recursion"), CBM_STORE_OK); + const struct { + const char *name; + const char *props; + const char *self; + const char *recursive; + } cases[] = { + {"countdown", "{\"self_recursive\":true,\"recursive\":true}", "true", "true"}, + {"visit", "{\"self_recursive\":true,\"recursive\":true}", "true", "true"}, + {"mutual", "{\"self_recursive\":false,\"recursive\":true}", "false", "true"}, + {"leaf", "{\"self_recursive\":false,\"recursive\":false}", "false", "false"}, + {"legacy", "{}", "null", "null"}, + {"partial", "{\"self_recursive\":true}", "true", "null"}, + {"invalid", "{\"self_recursive\":\"true\",\"recursive\":1}", "null", "null"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char qn[128]; + snprintf(qn, sizeof(qn), "%s.%s", proj, cases[i].name); + cbm_node_t n = {.project = proj, + .label = i == 1 ? "Method" : "Function", + .name = cases[i].name, + .qualified_name = qn, + .file_path = "recursion.ts", + .start_line = 1, + .end_line = 5, + .properties_json = cases[i].props}; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + for (int json = 0; json < 2; json++) { + for (int inbound = 0; inbound < 2; inbound++) { + char args[512]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"%s\"," + "\"direction\":\"%s\",\"format\":\"%s\"}", + proj, cases[i].name, inbound ? "inbound" : "outbound", + json ? "json" : "tree"); + char *response = cbm_mcp_handle_tool(srv, "trace_path", args); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + char expected[128]; + snprintf(expected, sizeof(expected), + json ? "\"self_recursive\":%s" : "self_recursive: %s\n", cases[i].self); + ASSERT_NOT_NULL(strstr(inner, expected)); + snprintf(expected, sizeof(expected), json ? "\"recursive\":%s" : "recursive: %s\n", + cases[i].recursive); + ASSERT_NOT_NULL(strstr(inner, expected)); + ASSERT_NOT_NULL(strstr(inner, json ? "\"self_call_policy\":\"omitted\"" + : "self_call_policy: omitted\n")); + snprintf(expected, sizeof(expected), json ? "\"%s_total\":0" : "%s_total: 0\n", + inbound ? "callers" : "callees"); + ASSERT_NOT_NULL(strstr(inner, expected)); + free(inner); + free(response); + } + } + } + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_trace_path_recursion_union_and_edges_issue2816) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "recursion-union"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/recursion-union"), CBM_STORE_OK); + cbm_node_t def = {.project = proj, + .label = "Function", + .name = "countdown", + .qualified_name = "recursion-union.impl.countdown", + .file_path = "impl.ts", + .start_line = 1, + .end_line = 10, + .properties_json = "{\"self_recursive\":false,\"recursive\":false}"}; + int64_t def_id = cbm_store_upsert_node(st, &def); + ASSERT_GT(def_id, 0); + cbm_node_t stub = def; + stub.qualified_name = "recursion-union.stub.countdown"; + stub.file_path = "stub.d.ts"; + stub.end_line = 1; + stub.properties_json = "{\"self_recursive\":true,\"recursive\":true}"; + ASSERT_GT(cbm_store_upsert_node(st, &stub), 0); + cbm_node_t leaf = def; + leaf.name = "leaf"; + leaf.qualified_name = "recursion-union.impl.leaf"; + leaf.properties_json = "{\"self_recursive\":true,\"recursive\":true}"; + int64_t leaf_id = cbm_store_upsert_node(st, &leaf); + ASSERT_GT(leaf_id, 0); + cbm_edge_t edge = {.project = proj, .source_id = def_id, .target_id = leaf_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &edge), 0); + const char *expected_flags[] = {"true", "null", "false", "true"}; + for (int state = 0; state < 4; state++) { + def.properties_json = state == 3 ? "{\"self_recursive\":true,\"recursive\":true}" + : "{\"self_recursive\":false,\"recursive\":false}"; + stub.properties_json = state == 0 ? "{\"self_recursive\":true,\"recursive\":true}" + : state == 2 ? "{\"self_recursive\":false,\"recursive\":false}" + : "{}"; + ASSERT_GT(cbm_store_upsert_node(st, &def), 0); + ASSERT_GT(cbm_store_upsert_node(st, &stub), 0); + for (int json = 0; json < 2; json++) { + char args[512]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"countdown\"," + "\"direction\":\"outbound\",\"format\":\"%s\"}", + proj, json ? "json" : "tree"); + char *response = cbm_mcp_handle_tool(srv, "trace_path", args); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, json ? "\"callees_total\":1" : "callees_total: 1\n")); + ASSERT_NOT_NULL(strstr(inner, json ? "\"leaf\"" : ".leaf")); + char expected[128]; + snprintf(expected, sizeof(expected), + json ? "\"self_recursive\":%s" : "self_recursive: %s\n", + expected_flags[state]); + ASSERT_NOT_NULL(strstr(inner, expected)); + snprintf(expected, sizeof(expected), json ? "\"recursive\":%s" : "recursive: %s\n", + expected_flags[state]); + ASSERT_NOT_NULL(strstr(inner, expected)); + free(inner); + free(response); + } + } + /* Recursion is root metadata, but the omission policy concerns CALLS only. */ + char *response = + cbm_mcp_handle_tool(srv, "trace_path", + "{\"project\":\"recursion-union\",\"function_name\":\"countdown\"," + "\"edge_types\":[\"IMPORTS\"],\"format\":\"json\"}"); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true")); + ASSERT_NULL(strstr(inner, "self_call_policy")); + free(inner); + free(response); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_trace_path_recursion_survives_budget_floor_issue2816) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "recursion-budget"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/recursion-budget"), CBM_STORE_OK); + char huge[4096]; + memset(huge, 'x', sizeof(huge) - 1); + huge[sizeof(huge) - 1] = '\0'; + cbm_node_t n = {.project = proj, + .label = "Function", + .name = "countdown", + .qualified_name = "recursion-budget.countdown", + .file_path = "recursion.ts", + .start_line = 1, + .end_line = 10, + .properties_json = "{\"self_recursive\":true,\"recursive\":true}"}; + int64_t root_id = cbm_store_upsert_node(st, &n); + ASSERT_GT(root_id, 0); + n.name = huge; + n.qualified_name = huge; + int64_t callee_id = cbm_store_upsert_node(st, &n); + ASSERT_GT(callee_id, 0); + cbm_edge_t edge = { + .project = proj, .source_id = root_id, .target_id = callee_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &edge), 0); + for (int json = 0; json < 2; json++) { + char args[512]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"countdown\"," + "\"direction\":\"outbound\",\"format\":\"%s\",\"max_output_tokens\":128}", + proj, json ? "json" : "tree"); + char *response = cbm_mcp_handle_tool(srv, "trace_path", args); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "output_budget")); + ASSERT_NULL(strstr(inner, huge)); + ASSERT_NOT_NULL(strstr(inner, json ? "\"self_recursive\":true" : "self_recursive: true\n")); + ASSERT_NOT_NULL(strstr(inner, json ? "\"recursive\":true" : "recursive: true\n")); + ASSERT_NOT_NULL(strstr(inner, json ? "\"self_call_policy\":\"omitted\"" + : "self_call_policy: omitted\n")); + free(inner); + free(response); + } + cbm_mcp_server_free(srv); + PASS(); +} + TEST(tool_trace_call_path_not_found) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -8100,7 +8294,8 @@ TEST(tool_trace_cursor_survives_output_budget_increase) { .qualified_name = "trace-budget-cursor.hub", .file_path = "src/hub.c", .start_line = 1, - .end_line = 2}; + .end_line = 2, + .properties_json = "{\"self_recursive\":true,\"recursive\":true}"}; cbm_node_t short_node = {.project = project, .label = "Function", .name = "short_row", @@ -8140,6 +8335,9 @@ TEST(tool_trace_cursor_survives_output_budget_increase) { "\"format\":\"json\"}"); char *inner = extract_text_content(response); ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"self_call_policy\":\"omitted\"")); ASSERT_NOT_NULL(strstr(inner, "short_row")); yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); ASSERT_NOT_NULL(doc); @@ -8160,6 +8358,9 @@ TEST(tool_trace_cursor_survives_output_budget_increase) { response = cbm_mcp_handle_tool(srv, "trace_path", args); inner = extract_text_content(response); ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"self_call_policy\":\"omitted\"")); ASSERT_NULL(strstr(inner, "cursor_params_mismatch")); ASSERT_NULL(strstr(inner, long_qn)); doc = yyjson_read(inner, strlen(inner), 0); @@ -8179,6 +8380,9 @@ TEST(tool_trace_cursor_survives_output_budget_increase) { response = cbm_mcp_handle_tool(srv, "trace_path", args); inner = extract_text_content(response); ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"self_call_policy\":\"omitted\"")); ASSERT_NULL(strstr(inner, "cursor_params_mismatch")); ASSERT_NULL(strstr(inner, "short_row")); doc = yyjson_read(inner, strlen(inner), 0); @@ -25961,6 +26165,9 @@ SUITE(mcp) { RUN_TEST(tool_index_status_includes_git_metadata); /* Tool handlers with validation */ + RUN_TEST(tool_trace_path_recursion_metadata_issue2816); + RUN_TEST(tool_trace_path_recursion_union_and_edges_issue2816); + RUN_TEST(tool_trace_path_recursion_survives_budget_floor_issue2816); RUN_TEST(tool_trace_call_path_not_found); RUN_TEST(tool_call_invalid_project_name_leaves_no_corrupt_litter_issue1425); RUN_TEST(tool_trace_missing_function_name); From 192fdb918f29c0ccfe8c030446e8fbb5ff116537 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 30 Oct 2026 22:55:31 +0100 Subject: [PATCH 66/97] 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 3c73982925..aa5aa0796c 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 d514868ee4701c8462a483d8d2050721f27e0ce4 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 30 Oct 2026 23:20:25 +0100 Subject: [PATCH 67/97] test(diagram): keep the unclosed class declaration in its own diagram A class line makes a PlantUML diagram a class diagram, where -> is an arrow rather than a sequence message, so the edge-message fixture could never produce the message arrow it asserts. Put the unclosed declaration in a second @startuml block and assert both diagram kinds. Signed-off-by: Martin Vogel --- tests/test_diagrams.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_diagrams.c b/tests/test_diagrams.c index cf1cc6e7bb..05d1f83231 100644 --- a/tests/test_diagrams.c +++ b/tests/test_diagrams.c @@ -331,7 +331,9 @@ TEST(diagrams_plantuml) { /* #2818: a sequence message to or from the diagram edge (`A ->: x`, `A <-: x`, * `[-> A: x`) and a declaration with an unclosed bracket have no operand id; - * they used to read an uninitialized one and crash the index worker. */ + * they used to read an uninitialized one and crash the index worker. The + * declaration sits in its own diagram: a `class` line makes a diagram a class + * diagram, where `->` is an arrow rather than a sequence message. */ TEST(diagrams_plantuml_edge_messages) { CBMFileResult *r = dg_file("@startuml\n" "participant Alice\n" @@ -339,10 +341,14 @@ TEST(diagrams_plantuml_edge_messages) { "Alice <-: in\n" "[-> Alice: from the edge\n" "Alice -> Bob : hello\n" + "@enduml\n" + "@startuml\n" "class [Unclosed\n" "@enduml\n", "docs/edge.puml"); ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_prop(dg_def(r, "edge#diagram-1"), "diagram_kind"), "sequence"); + ASSERT_STR_EQ(dg_prop(dg_def(r, "edge#diagram-2"), "diagram_kind"), "class"); ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Alice")); ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Bob")); ASSERT_EQ(r->diagram_arrows.count, 1); From 9bb891b669a3ae527841454d56eeed66498814b1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 30 Oct 2026 23:44:25 +0100 Subject: [PATCH 68/97] ci: move macOS runners to macos-15 and pin the deployment target to 14.0 GitHub retires the macos-14 runner image on 2026-11-02, and its brownouts already cancel our macos-14 jobs. Move every macos-14 runner reference to macos-15 (arm64): the release darwin-arm64 build, the unix-macos14 and tsan-mac test legs, the smoke-mac PR leg (its two matrix.os guards move in lockstep), the release smoke, both soak legs, the memwaste event lane, the bug-repro board, the cache warmer and the all-platform smoke. Lane names stay unchanged: select-lanes.sh, ci-ok and the lane contracts key on them, never on the runner label. Two broad legs already ran on macos-15 (_test.yml BROAD_UNIX, _smoke.yml BROAD_UNIX) and smoke.yml listed both images, so the moved legs would have duplicated them. Those duplicates are dropped; PR CI keeps exactly the same legs. Without an explicit minimum, clang stamps the binary with the SDK's default (LC_BUILD_VERSION minos), so a release built on macos-15 would refuse to start on macOS 14. Makefile.cbm now folds -mmacosx-version-min=14.0 into CC/CXX next to ARCHFLAGS for Apple targets (detected from the compiler's target, not the host), so it reaches every compile and link, vendored objects included, on every build path. Proof on macOS 26.6 / SDK 26.5: the production binary built from origin/main reports minos 26.0; with this change it reports minos 14.0, with zero warnings under -Werror, no weak imports and an identical undefined-symbol set. The test runner (direct make, no env.sh) is minos 14.0 too; platform and daemon_ipc suites pass. Signed-off-by: Martin Vogel --- .github/workflows/_build.yml | 4 +++- .github/workflows/_memwaste.yml | 2 +- .github/workflows/_smoke.yml | 9 +++++---- .github/workflows/_soak.yml | 4 ++-- .github/workflows/_test.yml | 11 +++++++---- .github/workflows/bug-repro.yml | 2 +- .github/workflows/cache-warm.yml | 2 +- .github/workflows/pr.yml | 6 +++--- .github/workflows/smoke.yml | 3 --- Makefile.cbm | 20 ++++++++++++++++++-- tests/test_lane_gate_contract.sh | 4 ++-- 11 files changed, 43 insertions(+), 24 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 47b5b17b5e..43c52b052a 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -41,7 +41,9 @@ jobs: goarch: arm64 cc: gcc cxx: g++ - - os: macos-14 + # The macOS minimum (14.0) is pinned in Makefile.cbm, not inherited + # from this runner's SDK, so both darwin binaries still start on macOS 14. + - os: macos-15 goos: darwin goarch: arm64 cc: cc diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index 174490db6c..09deb0c37e 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -115,7 +115,7 @@ jobs: event-macos: if: ${{ inputs.mode == 'full' }} - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 1a4d4eb57c..96c47d44f1 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -33,7 +33,7 @@ jobs: CORE_UNIX='[ {"os":"ubuntu-latest","goos":"linux","goarch":"amd64"}, {"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64"}, - {"os":"macos-14","goos":"darwin","goarch":"arm64"}, + {"os":"macos-15","goos":"darwin","goarch":"arm64"}, {"os":"macos-15-intel","goos":"darwin","goarch":"amd64"} ]' # Broad legs reuse existing goos/goarch artifacts on additional OS @@ -47,9 +47,10 @@ jobs: # (ubuntu-22.04 / 22.04-arm). So the dynamic broad legs stay on # forward-compatible OSes only (macOS); running the dynamic binary on # ubuntu-22.04 would fail Phase 1 (glibc too old), not a real regression. - BROAD_UNIX='[ - {"os":"macos-15","goos":"darwin","goarch":"arm64"} - ]' + # Empty since the move off macos-14 (retired 2026-11-02): the darwin-arm64 + # artifact is now built AND smoked on macos-15, the core leg, which was + # this broad leg. + BROAD_UNIX='[]' CORE_WIN='[{"os":"windows-latest","arch":"amd64"}]' # windows-11-arm now runs the NATIVE arm64 binary (build-windows-arm64), # not the x86_64 binary under emulation — we smoke the artifact we ship. diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index abf365f541..63c3ec51e7 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -44,7 +44,7 @@ jobs: goarch: arm64 cc: gcc cxx: g++ - - os: macos-14 + - os: macos-15 goos: darwin goarch: arm64 cc: cc @@ -335,7 +335,7 @@ jobs: goarch: arm64 cc: gcc cxx: g++ - - os: macos-14 + - os: macos-15 goos: darwin goarch: arm64 cc: cc diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 39baa516c1..55a8bf6085 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -57,18 +57,21 @@ jobs: run: | # Every CORE leg is tagged with the lane (scripts/ci/select-lanes.sh) # it serves; the tag is what lane selection filters on below. + # The lane names unix-macos14 and tsan-mac are kept as lane keys + # (select-lanes.sh, ci-ok and the lane contracts key on them); both + # run on macos-15 because GitHub retires the macos-14 image on 2026-11-02. CORE_UNIX='[ {"os":"ubuntu-latest","cc":"gcc","cxx":"g++","lane":"unix-x86"}, {"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++","lane":"unix-arm64"}, - {"os":"macos-14","cc":"cc","cxx":"c++","lane":"unix-macos14"}, + {"os":"macos-15","cc":"cc","cxx":"c++","lane":"unix-macos14"}, {"os":"macos-15-intel","cc":"cc","cxx":"c++","lane":"unix-macos-intel"} ]' # Broad matrix legs are REQUIRED gates (no optional/continue-on-error # escape hatches): the release dry run must be all-green, every platform. + # macos-15 is the CORE macOS arm64 leg now, so it is not repeated here. BROAD_UNIX='[ {"os":"ubuntu-22.04","cc":"gcc","cxx":"g++"}, - {"os":"ubuntu-22.04-arm","cc":"gcc","cxx":"g++"}, - {"os":"macos-15","cc":"cc","cxx":"c++"} + {"os":"ubuntu-22.04-arm","cc":"gcc","cxx":"g++"} ]' # Each Windows leg pins the msys2 environment + package arch to the # RUNNER architecture so the build is native, never emulated: @@ -92,7 +95,7 @@ jobs: TSAN='[ {"os":"ubuntu-latest","cc":"clang","cxx":"clang++","lane":"tsan-x86"}, {"os":"ubuntu-24.04-arm","cc":"clang","cxx":"clang++","lane":"tsan-arm"}, - {"os":"macos-14","cc":"cc","cxx":"c++","lane":"tsan-mac"} + {"os":"macos-15","cc":"cc","cxx":"c++","lane":"tsan-mac"} ]' # Lane selection. "all" (dry run, release) keeps every leg and drops # the tags, so those matrices -- and their job names -- stay exactly diff --git a/.github/workflows/bug-repro.yml b/.github/workflows/bug-repro.yml index 2910606d03..e3e1a647a6 100644 --- a/.github/workflows/bug-repro.yml +++ b/.github/workflows/bug-repro.yml @@ -40,7 +40,7 @@ jobs: group: linux cc: gcc cxx: g++ - - os: macos-14 + - os: macos-15 group: macos cc: cc cxx: c++ diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index ff15fbb1a7..bca57b23b7 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -27,7 +27,7 @@ jobs: include: - { os: ubuntu-latest, cc: gcc, cxx: g++ } - { os: ubuntu-24.04-arm, cc: gcc, cxx: g++ } - - { os: macos-14, cc: cc, cxx: c++ } + - { os: macos-15, cc: cc, cxx: c++ } - { os: macos-15-intel, cc: cc, cxx: c++ } runs-on: ${{ matrix.os }} timeout-minutes: 60 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 81a838e804..aade2b93da 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -175,7 +175,7 @@ jobs: cat "$RUNNER_TEMP/selection.txt" >> "$GITHUB_OUTPUT" # pr-smoke's legs, each tagged with its lane (kept in the job name, # where ci-ok reads it back); the job gets the selected ones. - SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]' + SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-15","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]' LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt") echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT" # The CodeQL legs, tagged the same way (codeql.yml's full matrix has @@ -214,7 +214,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache - name: Install ccache (macOS) - if: matrix.os == 'macos-14' + if: matrix.os == 'macos-15' run: command -v ccache >/dev/null 2>&1 || brew install ccache - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 @@ -254,7 +254,7 @@ jobs: SMOKE_REQUIRE_WRAPPERS: "1" - name: Build prod + smoke (macOS) - if: matrix.os == 'macos-14' + if: matrix.os == 'macos-15' run: | scripts/build.sh CC=cc CXX=c++ codesign --sign - --force build/c/codebase-memory-mcp diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index e9b4f93575..2de8e1b8e4 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -39,9 +39,6 @@ jobs: - os: ubuntu-24.04-arm cc: gcc cxx: g++ - - os: macos-14 # arm64 - cc: cc - cxx: c++ - os: macos-15 # arm64 cc: cc cxx: c++ diff --git a/Makefile.cbm b/Makefile.cbm index 3c73982925..12f0fe5c12 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -18,8 +18,24 @@ # that use their own *_CFLAGS — and so it survives a command-line `CC=` override. # ARCHFLAGS is empty on Linux/Windows and for native direct `make` invocations, # leaving CC/CXX unchanged there. -override CC := $(CC) $(ARCHFLAGS) -override CXX := $(CXX) $(ARCHFLAGS) +# +# Minimum macOS version (Apple targets only). Without one, clang takes the +# minimum from the SDK it compiles against (LC_BUILD_VERSION minos), so a +# release built on a macos-15 runner would refuse to start on macOS 14. 14.0 +# keeps every macOS that could run a release built on the macos-14 runner +# (retired 2026-11-02). A flag folded into the drivers like ARCHFLAGS, not an +# exported MACOSX_DEPLOYMENT_TARGET: it reaches every compile AND link on every +# build path (build.sh, test.sh, direct make) and is part of each command line. +# Detected from the compiler's target, not the host, so a cross compiler run +# on a Mac is left untouched. +MACOS_MIN_VERSION := 14.0 +IS_APPLE_TARGET := $(shell echo | $(CC) $(ARCHFLAGS) -dM -E - 2>/dev/null | grep -q '__APPLE__' && echo yes || echo no) +MACOS_MIN_FLAGS := +ifeq ($(IS_APPLE_TARGET),yes) +MACOS_MIN_FLAGS := -mmacosx-version-min=$(MACOS_MIN_VERSION) +endif +override CC := $(CC) $(ARCHFLAGS) $(MACOS_MIN_FLAGS) +override CXX := $(CXX) $(ARCHFLAGS) $(MACOS_MIN_FLAGS) # ── Common flags ───────────────────────────────────────────────── diff --git a/tests/test_lane_gate_contract.sh b/tests/test_lane_gate_contract.sh index 57e2cc12ee..f016f2e99d 100755 --- a/tests/test_lane_gate_contract.sh +++ b/tests/test_lane_gate_contract.sh @@ -49,7 +49,7 @@ t1_jobs() { # the jobs of a green T1 run; $1 = conclusion of the x86 leg job "lint / lint-mem" success job "test / setup-matrix" success job "test / test-unix (ubuntu-latest, gcc, g++, unix-x86, 1/1)" "$1" - job "test / test-unix (macos-14, cc, c++, unix-macos14, 1/1)" success + job "test / test-unix (macos-15, cc, c++, unix-macos14, 1/1)" success job "test / test-windows (windows-latest, CLANG64, x86_64, windows, 1/1)" success job "test / test-windows-guards" skipped job "test / test-package-wrappers" skipped @@ -116,7 +116,7 @@ gate "a selection without the job list is red" 1 "$NEEDS_OK" "$T1" "$WORK/does-n job "lint / lint" success job "lint / lint-mem" success job "test / test-windows-guards" success - job "pr-smoke (macos-14, smoke-mac)" success + job "pr-smoke (macos-15, smoke-mac)" success } >"$WORK/guards-only" gate "windows-guards is not the windows leg" 1 "$NEEDS_OK" '["lint","lint-mem","security-static","windows"]' "$WORK/guards-only" gate "pr-smoke matrix name maps to its lane" 0 "$NEEDS_OK" '["lint","lint-mem","security-static","smoke-mac","windows-guards"]' "$WORK/guards-only" From 3a869ee1ac6321a9a869976c1b3d39599ac25230 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 01:02:43 +0100 Subject: [PATCH 69/97] fix(hooks): state the CLI fallback for subagents as a fact Hook context reads best as plain facts: the SubagentStart sentence now says the same tools are also available from the shell instead of instructing the agent. The command the tests match is unchanged. Signed-off-by: Martin Vogel --- src/cli/hook_augment.c | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index d0120e89ca..ddee6aaa08 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1710,9 +1710,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root /* SubagentStart carries no tool list, so agents defined without MCP tools * (e.g. tools: Read, Grep, Glob, Bash) still get this; point them at the CLI. */ const char *cli_hint = strcmp(event, "SubagentStart") == 0 - ? " If the codebase-memory MCP tools are not available to you, run " - "the same tools from the shell: `codebase-memory-mcp cli " - "--help` shows each tool's flags." + ? " The same tools are also available from the shell: " + "`codebase-memory-mcp cli --help` lists each tool's flags." : ""; if (project) { char safe_project[HA_METADATA_CAP]; From c548d8e46a820cb3eb27de55fb1d0a54dab628c1 Mon Sep 17 00:00:00 2001 From: Divakant Pandey Date: Sat, 31 Oct 2026 14:16:04 +0530 Subject: [PATCH 70/97] 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 7d428b8e31..35313452bd 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 1df1fe1553..2a9549f266 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 1f57d1c255..830cacfe12 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 4dbfae9e9c..f495e8d5ba 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 23dff81503..4e19f06ef8 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 9d318f8c06..7c72681d24 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 94edecc292..919dbbdb24 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 71/97] 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 49d59d064e..77196e8109 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 72/97] 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 1df1fe1553..3deca96228 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 4dbfae9e9c..2c264b07d6 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 b51573deb8..e29290c747 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 34261302dd..9e90b049c4 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 ee1eeb9439..997d001949 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 303af90f72..20a9bf0dc0 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 9e99376baa..b711e32edc 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 73/97] 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 794e775c03..c20a98c365 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 7d3b542b83..0fcf6ab3eb 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 74/97] 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 7be8f6950c..f3b6098efa 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 9e6b6fc32c..ed06236a9d 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 @@ static int adr_copy_legacy_once(cbm_store_t *s, sqlite3 *adb) { * 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 bb7990c9ed..abf1415da6 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 75/97] 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 35313452bd..f6b5a0a00a 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 3b31f62e1f..ef765a3d5d 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 919dbbdb24..b7c125086b 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 76/97] 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 2a9549f266..1df1fe1553 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 4e19f06ef8..23dff81503 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 77/97] 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 7c72681d24..0bf14c68ba 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 78/97] 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 f495e8d5ba..89cc54b5d3 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 b7c125086b..cd346936d5 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 d5cf735ce0ddaa0135f16342657dd99178036b8b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 26 Sep 2026 11:15:23 +0200 Subject: [PATCH 79/97] test(elixir): pin the fingerprint half of the complexity/fingerprint change The carried #1731 test asserted complexity and line counts but never the fingerprint, which is half of what the change adds. A body past the MinHash size gate must now carry one, and a one-expression body must not. Raised in the 2026-08-20 review. Signed-off-by: Martin Vogel --- tests/test_extraction.c | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 7bc8a34998..4c70057721 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2749,18 +2749,34 @@ TEST(elixir_complexity_and_fingerprint) { " end\n" " end\n" " def plain(a), do: a\n" + " def summarize(orders, currency) do\n" + " totals = Enum.map(orders, fn order -> order.amount * order.rate end)\n" + " count = length(totals)\n" + " sum = Enum.reduce(totals, 0, fn value, acc -> acc + value end)\n" + " average = if count > 0, do: sum / count, else: 0\n" + " label = String.upcase(currency) <> \" total\"\n" + " %{label: label, sum: sum, count: count, average: average}\n" + " end\n" "end\n", CBM_LANG_ELIXIR, "t", "cx.ex"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); CBMDefinition *cls = NULL; CBMDefinition *plain = NULL; + CBMDefinition *summarize = NULL; for (int i = 0; i < r->defs.count; i++) { if (strcmp(r->defs.items[i].name, "classify") == 0) cls = &r->defs.items[i]; if (strcmp(r->defs.items[i].name, "plain") == 0) plain = &r->defs.items[i]; - } + if (strcmp(r->defs.items[i].name, "summarize") == 0) + summarize = &r->defs.items[i]; + } + /* A body past the MinHash size gate gets a fingerprint (Elixir used to emit + * none at all); a one-expression body stays below the gate and gets none. */ + ASSERT_NOT_NULL(summarize); + ASSERT(summarize->fingerprint_k > 0); + ASSERT_NOT_NULL(summarize->fingerprint); ASSERT_NOT_NULL(cls); /* The `case` itself + its 3 arms + the second arm's own `when` guard. */ ASSERT_EQ(5, cls->complexity); @@ -2771,6 +2787,7 @@ TEST(elixir_complexity_and_fingerprint) { ASSERT_NOT_NULL(plain); ASSERT_EQ(0, plain->complexity); ASSERT(plain->lines > 0); + ASSERT_EQ(0, plain->fingerprint_k); cbm_free_result(r); PASS(); } From b19ac1e923b41ff753eb8d0cf5dc695394f408bc Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 21:30:27 +0100 Subject: [PATCH 80/97] test(elixir): the same body shape under other names shares a fingerprint The 2026-08-20 review asked for the fingerprint to be equal for two structurally identical functions with different names, since duplicate detection is what it exists for. tally repeats the body of summarize with every function and variable name changed, and both must carry the same MinHash values. The fingerprint hashes normalised leaf token kinds, so names never enter it. Signed-off-by: Martin Vogel --- tests/test_extraction.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 4c70057721..7b921b4685 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2757,6 +2757,14 @@ TEST(elixir_complexity_and_fingerprint) { " label = String.upcase(currency) <> \" total\"\n" " %{label: label, sum: sum, count: count, average: average}\n" " end\n" + " def tally(items, unit) do\n" + " values = Enum.map(items, fn item -> item.price * item.qty end)\n" + " n = length(values)\n" + " total = Enum.reduce(values, 0, fn v, a -> a + v end)\n" + " mean = if n > 0, do: total / n, else: 0\n" + " tag = String.upcase(unit) <> \" total\"\n" + " %{tag: tag, total: total, n: n, mean: mean}\n" + " end\n" "end\n", CBM_LANG_ELIXIR, "t", "cx.ex"); ASSERT_NOT_NULL(r); @@ -2764,6 +2772,7 @@ TEST(elixir_complexity_and_fingerprint) { CBMDefinition *cls = NULL; CBMDefinition *plain = NULL; CBMDefinition *summarize = NULL; + CBMDefinition *tally = NULL; for (int i = 0; i < r->defs.count; i++) { if (strcmp(r->defs.items[i].name, "classify") == 0) cls = &r->defs.items[i]; @@ -2771,12 +2780,21 @@ TEST(elixir_complexity_and_fingerprint) { plain = &r->defs.items[i]; if (strcmp(r->defs.items[i].name, "summarize") == 0) summarize = &r->defs.items[i]; + if (strcmp(r->defs.items[i].name, "tally") == 0) + tally = &r->defs.items[i]; } /* A body past the MinHash size gate gets a fingerprint (Elixir used to emit * none at all); a one-expression body stays below the gate and gets none. */ ASSERT_NOT_NULL(summarize); ASSERT(summarize->fingerprint_k > 0); ASSERT_NOT_NULL(summarize->fingerprint); + /* Duplicate detection is what the fingerprint exists for: the same body + * shape under other function and variable names gets the same fingerprint. */ + ASSERT_NOT_NULL(tally); + ASSERT_EQ(summarize->fingerprint_k, tally->fingerprint_k); + ASSERT_NOT_NULL(tally->fingerprint); + ASSERT(memcmp(summarize->fingerprint, tally->fingerprint, + (size_t)summarize->fingerprint_k * sizeof(summarize->fingerprint[0])) == 0); ASSERT_NOT_NULL(cls); /* The `case` itself + its 3 arms + the second arm's own `when` guard. */ ASSERT_EQ(5, cls->complexity); From aa3161fd817e646dbadb9ff0cfd6a001b0b39f91 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 21:31:06 +0100 Subject: [PATCH 81/97] fix(elixir): grow the complexity walk stack instead of capping it elixir_compute_complexity kept its pending frames in one fixed 4096-entry array (48 bytes each, 196,608 bytes in a single stack frame) and stopped pushing children once it was full. Children are pushed last to first, so a wider body lost its FIRST children and the function reported a lower complexity with no signal, on exactly the largest functions. Worker threads do not get the 8 MB main-thread stack, so the array was a risk of its own. It now uses the walker pattern cbm_compute_complexity has used since #2375: a 512-frame on-stack first chunk that spills to the heap through cbm_walk_stack_reserve (memory core, class EXTRACT) and is released with cbm_walk_stack_release. If the stack cannot grow, every metric reports CBM_WALK_METRIC_UNAVAILABLE instead of an undercount. This answers items 2 and 3 of the 2026-08-20 review. The new test builds a 5000-statement body of alternating if and for. The fixed stack reports complexity 4095 for it instead of 5000. Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 27 +++++++++++++++++++++++---- tests/test_extraction.c | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index c9276df5f3..1acd9d93bb 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9888,14 +9888,22 @@ static bool elixir_is_branch_operator(TSNode node, const char *source) { elixir_node_text_is(op, source, "when"); } +/* The pending frames live on the same growable stack as cbm_compute_complexity: + * a small on-stack first chunk that spills to the heap through + * cbm_walk_stack_reserve, so a wide body is never truncated and no thread pays + * for a large fixed array. If the stack cannot grow, every metric reports + * CBM_WALK_METRIC_UNAVAILABLE instead of a silently low count. */ static void elixir_compute_complexity(TSNode node, const char *source, cbm_complexity_t *out) { - enum { ELIXIR_CX_STACK_CAP = 4096 }; - struct { + enum { ELIXIR_CX_STACK_INLINE = 512 }; + struct elixir_cx_frame { TSNode node; int bdepth; int ldepth; int adepth; - } stack[ELIXIR_CX_STACK_CAP]; + }; + struct elixir_cx_frame inline_stack[ELIXIR_CX_STACK_INLINE]; + struct elixir_cx_frame *stack = inline_stack; + int cap = ELIXIR_CX_STACK_INLINE; memset(out, 0, sizeof(*out)); int top = 0; stack[top].node = node; @@ -9943,7 +9951,17 @@ static void elixir_compute_complexity(TSNode node, const char *source, cbm_compl child_l = d; } uint32_t nc = ts_node_child_count(n); - for (int i = (int)nc - 1; i >= 0 && top < ELIXIR_CX_STACK_CAP; i--) { + if (!cbm_walk_stack_reserve((void **)&stack, &cap, top + (int)nc, + sizeof(struct elixir_cx_frame), inline_stack, + "elixir_compute_complexity")) { + out->cyclomatic = CBM_WALK_METRIC_UNAVAILABLE; + out->cognitive = CBM_WALK_METRIC_UNAVAILABLE; + out->loop_count = CBM_WALK_METRIC_UNAVAILABLE; + out->loop_depth = CBM_WALK_METRIC_UNAVAILABLE; + out->max_access_depth = CBM_WALK_METRIC_UNAVAILABLE; + break; + } + for (int i = (int)nc - 1; i >= 0; i--) { stack[top].node = ts_node_child(n, (uint32_t)i); stack[top].bdepth = child_b; stack[top].ldepth = child_l; @@ -9951,6 +9969,7 @@ static void elixir_compute_complexity(TSNode node, const char *source, cbm_compl top++; } } + cbm_walk_stack_release(stack, inline_stack); } static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro, diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 7b921b4685..8e7231ccd8 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2810,6 +2810,40 @@ TEST(elixir_complexity_and_fingerprint) { PASS(); } +/* The Elixir complexity walk keeps its pending nodes on the growable stack + * cbm_compute_complexity uses. A fixed 4096-frame stack dropped the FIRST + * children of any wider body (they are pushed last-to-first), so the largest + * functions silently under-reported. This body is wider than that old cap. */ +enum { ELIXIR_WIDE_STMTS = 5000 }; + +TEST(elixir_complexity_wide_body_exceeds_old_stack_cap) { + static const char head[] = "defmodule Wide do\n def wide(x) do\n"; + static const char if_stmt[] = " if x, do: :a\n"; + static const char for_stmt[] = " for y <- x, do: y\n"; + static const char tail[] = " end\nend\n"; + size_t cap = sizeof(head) + (size_t)ELIXIR_WIDE_STMTS * sizeof(for_stmt) + sizeof(tail); + char *src = malloc(cap); + ASSERT_NOT_NULL(src); + size_t pos = (size_t)snprintf(src, cap, "%s", head); + for (int i = 0; i < ELIXIR_WIDE_STMTS; i++) { + pos += (size_t)snprintf(src + pos, cap - pos, "%s", (i % 2 == 0) ? if_stmt : for_stmt); + } + snprintf(src + pos, cap - pos, "%s", tail); + CBMFileResult *r = extract(src, CBM_LANG_ELIXIR, "t", "wide.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *d = elixir_first_def(r, "wide"); + ASSERT_NOT_NULL(d); + /* every `if` and every `for` is one branching call at nesting depth 0 */ + ASSERT_EQ(ELIXIR_WIDE_STMTS, d->complexity); + ASSERT_EQ(ELIXIR_WIDE_STMTS, d->cognitive); + ASSERT_EQ(ELIXIR_WIDE_STMTS / 2, d->loop_count); + ASSERT_EQ(1, d->loop_depth); + cbm_free_result(r); + free(src); + PASS(); +} + /* --- Haskell --- */ TEST(haskell_function) { CBMFileResult *r = extract("add :: Int -> Int -> Int\nadd x y = x + y\n\nmultiply :: Int -> " @@ -14615,6 +14649,7 @@ SUITE(extraction) { RUN_TEST(extract_elixir_guarded_def_head_guard_usages); RUN_TEST(elixir_channels_pubsub_and_handle_in); RUN_TEST(elixir_complexity_and_fingerprint); + RUN_TEST(elixir_complexity_wide_body_exceeds_old_stack_cap); RUN_TEST(haskell_function); RUN_TEST(haskell_infix_definition_name); RUN_TEST(ocaml_function); From 2caf51a4c6556a9d60d330758b1397ef7b804d95 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 21:31:23 +0100 Subject: [PATCH 82/97] fix(elixir): a folded multi-clause function carries the metrics of every clause Since #2371 the clauses of a multi-clause function fold into one Function node whose span covers all of them, but only the first clause was measured: the folded node kept the complexity, cognitive, loop and access metrics and the line count of that clause while its span grew. fold_elixir_clause now receives the metrics of each clause it folds and adds them to the node: cyclomatic, cognitive and loop_count add up, loop_depth and max_access_depth take the deepest clause, every clause past the first adds one dispatch decision to cyclomatic, and lines is recomputed from the folded span. A clause whose walk reports CBM_WALK_METRIC_UNAVAILABLE makes the folded value unavailable rather than a partial sum. This is the treatment proposed in the PR description and agreed in the review thread. Indexed elixir-lang/elixir (669f8481, 6703 Elixir Function nodes) against the merge parent: the 2212 folded functions change, the 4491 single-clause functions keep identical metrics, Functions with complexity above 0 go from 3412 to 4785, and every node now has lines equal to its span (4491 did before). Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 39 +++++++++++++++++++++++++--- tests/test_extraction.c | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 1acd9d93bb..8f759e035d 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9714,6 +9714,24 @@ static TSNode elixir_call_args(TSNode node) { return args; } +/* Combine one folded clause's metric with the function's value so far. A clause + * whose walk could not finish reports CBM_WALK_METRIC_UNAVAILABLE, and so does + * the folded function: a sum that skipped that clause would read low on exactly + * the largest functions. */ +static int elixir_fold_metric_sum(int acc, int clause) { + if (acc < 0 || clause < 0) { + return CBM_WALK_METRIC_UNAVAILABLE; + } + return acc + clause; +} + +static int elixir_fold_metric_max(int acc, int clause) { + if (acc < 0 || clause < 0) { + return CBM_WALK_METRIC_UNAVAILABLE; + } + return clause > acc ? clause : acc; +} + // 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 @@ -9792,9 +9810,10 @@ static TSNode elixir_call_args(TSNode node) { // 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. +// `cx` is this clause's own complexity; a folded clause adds it to the function. // 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) { + uint32_t end_line, bool is_exported, const cbm_complexity_t *cx) { if (!qn || ctx->result->defs.count <= 0 || ts_node_is_null(scope)) { return false; } @@ -9819,6 +9838,18 @@ static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, prev->end_line = end_line; } prev->is_exported = prev->is_exported || is_exported; + /* One node now stands for every clause, so its metrics cover every clause + * rather than the first: branch and loop counts add up, the nesting depths + * take the deepest clause, and each clause past the first is one more + * dispatch decision. A multi-clause function with no branch in any body is + * still a decision tree over its heads. `lines` follows the widened span. */ + int cyclomatic = elixir_fold_metric_sum(prev->complexity, cx->cyclomatic); + prev->complexity = cyclomatic < 0 ? cyclomatic : cyclomatic + 1; + prev->cognitive = elixir_fold_metric_sum(prev->cognitive, cx->cognitive); + prev->loop_count = elixir_fold_metric_sum(prev->loop_count, cx->loop_count); + prev->loop_depth = elixir_fold_metric_max(prev->loop_depth, cx->loop_depth); + prev->max_access_depth = elixir_fold_metric_max(prev->max_access_depth, cx->max_access_depth); + prev->lines = (int)(prev->end_line - prev->start_line + TS_LINE_OFFSET); return true; } @@ -10010,7 +10041,9 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char 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); + cbm_complexity_t cx; + elixir_compute_complexity(node, ctx->source, &cx); + bool folded = fold_elixir_clause(ctx, qn, *scope, node, end_line, is_exported, &cx); *scope = ts_node_parent(node); if (folded) { return; @@ -10026,8 +10059,6 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char def.end_line = end_line; def.is_exported = is_exported; def.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET); - cbm_complexity_t cx; - elixir_compute_complexity(node, ctx->source, &cx); def.complexity = cx.cyclomatic; def.cognitive = cx.cognitive; def.loop_count = cx.loop_count; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 8e7231ccd8..29fd1cc3c1 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2810,6 +2810,57 @@ TEST(elixir_complexity_and_fingerprint) { PASS(); } +/* The clauses of a multi-clause function fold into one Function node, so that + * node's metrics must cover every clause, not just the first: branch, cognitive + * and loop counts add up, loop_depth and max_access_depth take the deepest + * clause, every clause past the first is one more dispatch decision, and + * `lines` is the folded span. `single` repeats the second clause on its own as + * the per-clause reference: it is not folded, so it carries no dispatch charge. */ +TEST(elixir_folded_clause_metrics_cover_every_clause) { + CBMFileResult *r = extract("defmodule Route do\n" /* 1 */ + " def pick(0), do: Map.new()\n" /* 2 */ + " def pick(n) when n > 0 do\n" /* 3 */ + " for x <- [n], do: Map.new([{x, x}])\n" /* 4 */ + " end\n" /* 5 */ + " def pick(rows) do\n" /* 6 */ + " for row <- rows, do: (for x <- row, do: x)\n" /* 7 */ + " end\n" /* 8 */ + " def single(n) when n > 0 do\n" /* 9 */ + " for x <- [n], do: Map.new([{x, x}])\n" /* 10 */ + " end\n" /* 11 */ + "end\n", /* 12 */ + CBM_LANG_ELIXIR, "t", "route.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_defs_named(r, "Function", "pick")); + const CBMDefinition *one = elixir_first_def(r, "single"); + ASSERT_NOT_NULL(one); + /* `when` + `for`, each at nesting 0; one comprehension; Map.new is one dot */ + ASSERT_EQ(2, one->complexity); + ASSERT_EQ(2, one->cognitive); + ASSERT_EQ(1, one->loop_count); + ASSERT_EQ(1, one->loop_depth); + ASSERT_EQ(1, one->max_access_depth); + ASSERT_EQ(3, one->lines); + + const CBMDefinition *pick = elixir_first_def(r, "pick"); + ASSERT_NOT_NULL(pick); + ASSERT_EQ(2, (int)pick->start_line); + ASSERT_EQ(8, (int)pick->end_line); + /* clauses 0 + 2 + 2, plus 2 for the two clauses past the first */ + ASSERT_EQ(6, pick->complexity); + /* 0 + 2 + (1 + 2): the inner comprehension sits one level deep */ + ASSERT_EQ(5, pick->cognitive); + ASSERT_EQ(3, pick->loop_count); + /* the deepest clause, not the sum of the clauses (that would be 3) */ + ASSERT_EQ(2, pick->loop_depth); + /* two clauses with one dot each: the deepest is 1, the sum would be 2 */ + ASSERT_EQ(1, pick->max_access_depth); + ASSERT_EQ(7, pick->lines); + cbm_free_result(r); + PASS(); +} + /* The Elixir complexity walk keeps its pending nodes on the growable stack * cbm_compute_complexity uses. A fixed 4096-frame stack dropped the FIRST * children of any wider body (they are pushed last-to-first), so the largest @@ -14649,6 +14700,7 @@ SUITE(extraction) { RUN_TEST(extract_elixir_guarded_def_head_guard_usages); RUN_TEST(elixir_channels_pubsub_and_handle_in); RUN_TEST(elixir_complexity_and_fingerprint); + RUN_TEST(elixir_folded_clause_metrics_cover_every_clause); RUN_TEST(elixir_complexity_wide_body_exceeds_old_stack_cap); RUN_TEST(haskell_function); RUN_TEST(haskell_infix_definition_name); From 3b33babd7c0b649942ad6728a483a492d1a81bb2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 21:31:39 +0100 Subject: [PATCH 83/97] style(elixir): put the definition comment back above extract_elixir_func_def The complexity helpers were inserted between the comment that documents extract_elixir_func_def and the function itself, so that comment read as the description of elixir_node_text_is. Move it back above the function it documents; the comment and signature are again byte-identical to main. No code change. Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 8f759e035d..e6ba01d7ec 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9853,9 +9853,6 @@ static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, 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. /* True when node `n` spans exactly the text `lit`. Avoids an arena allocation * per node in the complexity walk, which visits every node of every def. */ static bool elixir_node_text_is(TSNode n, const char *source, const char *lit) { @@ -10003,6 +10000,9 @@ static void elixir_compute_complexity(TSNode node, const char *source, cbm_compl cbm_walk_stack_release(stack, inline_stack); } +// 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; From 98a66b73566cb1129a35dc6b6c4b236706b41f63 Mon Sep 17 00:00:00 2001 From: HazielMagallanes Date: Sat, 31 Oct 2026 20:42:36 -0300 Subject: [PATCH 84/97] fix(cli): emit OpenCode plugin hooks for the V2 setup context - keep the `server(ctx)` entry that OpenCode 1.18.x dispatches hooks from - fill `setup(ctx)` with the V2 hook registrations (execute.after, compaction) and fail open per domain, so one unavailable registration neither breaks the host nor disables the other - prefer `content` over `output` in appendResult and return the result unchanged when no text surface exists, so a tool's own output is never hidden - persist the post-compaction note as a keyed session instruction (`codebase-memory-session-note`) that survives compaction instead of editing the system prompt; hosts without that channel skip reinjection - resolve each session's directory with ctx.session.get (cached), because ctx.location is the plugin instance's location, not the session's - update the #2089 emission test in place to state the 2.x contract Fixes #2204 Refs #2077 #2089 Signed-off-by: HazielMagallanes --- src/cli/client_adapter.c | 255 ++++++++++++++++++++++++++++--------- src/cli/client_adapter.h | 16 ++- tests/test_agent_clients.c | 53 +++++++- 3 files changed, 253 insertions(+), 71 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index cfd2719c78..60cb0cd3d7 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -427,9 +427,15 @@ char *cbm_client_adapter_opencode(const char *binary_path) { sb_append(&sb, "// OpenCode already reaches every tool over MCP; this module adds the\n" "// context surfaces other clients get through hook configuration: graph\n" "// lookup after grep/glob, index-coverage notes after read, session-start\n" - "// tier routing (carried on the first tool result of each session, since\n" - "// OpenCode documents no context-output lifecycle hook), and reinjection\n" - "// after compaction via the documented experimental surface.\n"); + "// tier routing (carried on the first tool result of each session), and\n" + "// reinjection after compaction through the session context channel.\n" + "//\n" + "// Two runtimes load this module:\n" + "// - OpenCode 1.18.x dispatches the hooks returned by server(ctx).\n" + "// - OpenCode 2 ignores server and calls setup(ctx), which registers the\n" + "// same behaviour on the context's tool and session hook domains. Each\n" + "// registration fails open, and the matching server() hook yields once\n" + "// its domain has registered.\n"); sb_append(&sb, "import { spawn } from 'node:child_process';\n\n"); sb_append(&sb, "const BIN = '"); sb_append(&sb, bin); @@ -481,64 +487,193 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " });\n" "}\n\n"); + /* OpenCode 2 tool results are structured (Tool.Result: content is the + * string/parts surface the model reads, output is the tool's schema-typed + * value). Append to content first, then a string output; if neither text + * surface exists, leave the result untouched so the tool's own output is + * never hidden. The caller assigns the returned result onto event.result. */ + sb_append(&sb, + "function appendResult(result, extra) {\n" + " if (!result || typeof result !== 'object') return result;\n" + " if (typeof result.content === 'string') {\n" + " return { ...result, content: result.content + '\\n' + extra };\n" + " }\n" + " if (Array.isArray(result.content)) {\n" + " const content = result.content.slice();\n" + " for (let i = content.length - 1; i >= 0; i--) {\n" + " if (content[i] && content[i].type === 'text') {\n" + " content[i] = { ...content[i], text: content[i].text + '\\n' + extra };\n" + " return { ...result, content };\n" + " }\n" + " }\n" + " content.push({ type: 'text', text: extra });\n" + " return { ...result, content };\n" + " }\n" + " if (typeof result.output === 'string') {\n" + " return { ...result, output: result.output + '\\n' + extra };\n" + " }\n" + " return result;\n" + "}\n\n"); + + /* Per-domain registration flags: a host that dispatches both server() and + * setup() with domains must not append the note twice. The server() hook + * for a domain yields once setup() registered that same domain. */ + sb_append(&sb, "let toolHooksRegistered = false;\n" + "let sessionHooksRegistered = false;\n\n"); + sb_append( - &sb, "export default {\n" - " id: 'codebase-memory-augment',\n" - " // V2 config loader: requires id + setup|effect; no tool domain yet\n" - " // at this point, so there is nothing to register here.\n" - " setup() {},\n" - " // Server runtime: reads default.server and dispatches the hooks it returns.\n" - " server: async (ctx) => {\n" - " const dir = ctx?.directory;\n" - " const seen = new Set();\n" - " const lifecycle = () =>\n" - " augment({ hook_event_name: 'SessionStart', cwd: dir });\n" - " return {\n" - " 'tool.execute.after': async (input, output) => {\n" - " if (typeof output?.output !== 'string') return;\n" - " const pieces = [];\n" - " const sid = input?.sessionID;\n" - " if (typeof sid === 'string' && !seen.has(sid)) {\n" - " seen.add(sid);\n" - " pieces.push(await lifecycle());\n" - " }\n" - " const args = input?.args ?? {};\n" - " const search =\n" - " input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n" - " if (search) {\n" - " pieces.push(await augment({\n" - " hook_event_name: 'PreToolUse',\n" - " tool_name: search,\n" - " tool_input: args,\n" - " cwd: dir,\n" - " }));\n" - " } else if (input?.tool === 'read') {\n" - " const filePath = args.filePath ?? args.file_path ?? args.path;\n" - " if (typeof filePath === 'string' && filePath) {\n" - " pieces.push(await augment({\n" - " hook_event_name: 'PostToolUse',\n" - " tool_name: 'Read',\n" - " tool_input: { file_path: filePath },\n" - " cwd: dir,\n" - " }));\n" - " }\n" - " }\n" - " const extra = pieces.filter(Boolean).join('\\n');\n" - " if (extra) {\n" - " output.output += '\\n' + extra;\n" - " }\n" - " },\n" - " // Documented (experimental) compaction surface: output.context is the\n" - " // mutable array of context strings for the rebuilt session.\n" - " 'experimental.session.compacting': async (_input, output) => {\n" - " const note = await lifecycle();\n" - " if (note && Array.isArray(output?.context)) {\n" - " output.context.push(note);\n" - " }\n" - " },\n" - " };\n" - " },\n" - "};\n"); + &sb, + "export default {\n" + " id: 'codebase-memory-augment',\n" + " // OpenCode 1.18.x server runtime: reads default.server and dispatches\n" + " // the hooks it returns.\n" + " server: async (ctx) => {\n" + " const dir = ctx?.directory;\n" + " const seen = new Set();\n" + " const lifecycle = () =>\n" + " augment({ hook_event_name: 'SessionStart', cwd: dir });\n" + " return {\n" + " 'tool.execute.after': async (input, output) => {\n" + " if (toolHooksRegistered) return;\n" + " if (typeof output?.output !== 'string') return;\n" + " const pieces = [];\n" + " const sid = input?.sessionID;\n" + " if (typeof sid === 'string' && !seen.has(sid)) {\n" + " seen.add(sid);\n" + " pieces.push(await lifecycle());\n" + " }\n" + " const args = input?.args ?? {};\n" + " const search =\n" + " input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n" + " if (search) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PreToolUse',\n" + " tool_name: search,\n" + " tool_input: args,\n" + " cwd: dir,\n" + " }));\n" + " } else if (input?.tool === 'read') {\n" + " const filePath = args.filePath ?? args.file_path ?? args.path;\n" + " if (typeof filePath === 'string' && filePath) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PostToolUse',\n" + " tool_name: 'Read',\n" + " tool_input: { file_path: filePath },\n" + " cwd: dir,\n" + " }));\n" + " }\n" + " }\n" + " const extra = pieces.filter(Boolean).join('\\n');\n" + " if (extra) {\n" + " output.output += '\\n' + extra;\n" + " }\n" + " },\n" + " // Documented (experimental) compaction surface: output.context is the\n" + " // mutable array of context strings for the rebuilt session.\n" + " 'experimental.session.compacting': async (_input, output) => {\n" + " if (sessionHooksRegistered) return;\n" + " const note = await lifecycle();\n" + " if (note && Array.isArray(output?.context)) {\n" + " output.context.push(note);\n" + " }\n" + " },\n" + " };\n" + " },\n" + " // OpenCode 2 runtime: default.server is ignored and setup(ctx) registers\n" + " // the hooks on the context domains. Each registration fails open, so one\n" + " // missing domain cannot break the host or the other domain.\n" + " async setup(ctx) {\n" + " const seen = new Set();\n" + " const dirs = new Map();\n" + " const dirFor = async (sessionID) => {\n" + " if (typeof sessionID !== 'string' || sessionID === '') return undefined;\n" + " if (dirs.has(sessionID)) return dirs.get(sessionID);\n" + " try {\n" + " const session = await ctx.session.get({ sessionID });\n" + " const dir = session?.location?.directory;\n" + " if (typeof dir === 'string' && dir !== '') {\n" + " dirs.set(sessionID, dir);\n" + " return dir;\n" + " }\n" + " } catch {}\n" + " return undefined;\n" + " };\n" + " const lifecycle = (dir) =>\n" + " augment({ hook_event_name: 'SessionStart', cwd: dir });\n" + " if (ctx?.tool?.hook) {\n" + " try {\n" + " await ctx.tool.hook('execute.after', async (event) => {\n" + " if (event?.status !== 'completed') return;\n" + " const dir = await dirFor(event?.sessionID);\n" + " if (!dir) return;\n" + " const pieces = [];\n" + " if (typeof event?.sessionID === 'string' && !seen.has(event.sessionID)) {\n" + " seen.add(event.sessionID);\n" + " pieces.push(await lifecycle(dir));\n" + " }\n" + " const args = event?.input ?? {};\n" + " const search =\n" + " event?.tool === 'grep' ? 'Grep' : event?.tool === 'glob' ? 'Glob' : null;\n" + " if (search) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PreToolUse',\n" + " tool_name: search,\n" + " tool_input: args,\n" + " cwd: dir,\n" + " }));\n" + " } else if (event?.tool === 'read') {\n" + " const filePath = args.filePath ?? args.file_path ?? args.path;\n" + " if (typeof filePath === 'string' && filePath) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PostToolUse',\n" + " tool_name: 'Read',\n" + " tool_input: { file_path: filePath },\n" + " cwd: dir,\n" + " }));\n" + " }\n" + " }\n" + " const extra = pieces.filter(Boolean).join('\\n');\n" + " if (extra) {\n" + " event.result = appendResult(event.result, extra);\n" + " }\n" + " });\n" + " toolHooksRegistered = true;\n" + " } catch {\n" + " // Fail open: the server() tool hook stays active.\n" + " }\n" + " }\n" + " // OpenCode 2 compaction: persist the note as a keyed session\n" + " // instruction (a entry) instead of editing the system\n" + " // prompt; entries survive compaction. Hosts without the channel\n" + " // skip reinjection rather than failing the plugin.\n" + " if (ctx?.session?.hook) {\n" + " try {\n" + " await ctx.session.hook('compaction', async (event) => {\n" + " const sessionID = event?.sessionID;\n" + " if (typeof sessionID !== 'string' || sessionID === '') return;\n" + " const entry = ctx?.session?.instructions?.entry;\n" + " if (typeof entry?.put !== 'function') return;\n" + " const dir = await dirFor(sessionID);\n" + " if (!dir) return;\n" + " const note = await lifecycle(dir);\n" + " if (!note) return;\n" + " try {\n" + " await entry.put({\n" + " sessionID,\n" + " key: 'codebase-memory-session-note',\n" + " value: note,\n" + " });\n" + " } catch {\n" + " // Fail open: reinjection is best-effort.\n" + " }\n" + " });\n" + " sessionHooksRegistered = true;\n" + " } catch {\n" + " // Fail open: the server() compaction hook stays active.\n" + " }\n" + " }\n" + " },\n" + "};\n"); if (sb.failed) { free(sb.buf); diff --git a/src/cli/client_adapter.h b/src/cli/client_adapter.h index c1bfb652e6..bbe197d89e 100644 --- a/src/cli/client_adapter.h +++ b/src/cli/client_adapter.h @@ -48,11 +48,17 @@ char *cbm_client_adapter_pi(const char *binary_path); * other clients get through their own native hook configuration. OpenCode has * no such configuration; a plugin module is its only extension point. * - * NOTE for maintainers: this hooks `tool.execute.after`, whose ability to - * modify a tool's output is NOT part of OpenCode's documented plugin contract - * (only `tool.execute.before`'s argument mutation is). If OpenCode changes it, - * augmentation stops silently — no error, no signal. That risk is accepted - * deliberately and recorded here so it is not rediscovered as a mystery. + * NOTE for maintainers: the module serves two loader generations from one + * default export. OpenCode 1.18.x dispatches the hooks returned by + * `server(ctx)`; its `tool.execute.after` ability to modify a tool's output is + * NOT part of the documented plugin contract (only `tool.execute.before`'s + * argument mutation is), so if that runtime changes, augmentation stops + * silently — no error, no signal. That risk is accepted deliberately for the + * server() path and recorded here so it is not rediscovered as a mystery. + * OpenCode 2 ignores `server` and calls `setup(ctx)`, which registers + * `execute.after` and `compaction` on the context domains. Each registration + * fails open, and the post-compaction note is stored as a keyed session + * instruction (`` entry) instead of the system prompt. * * Returns NULL on allocation failure or when binary_path is NULL/empty. */ char *cbm_client_adapter_opencode(const char *binary_path); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 0d57723a07..a3afe14944 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1342,18 +1342,59 @@ TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) { PASS(); } -/* #2077: OpenCode's V2 loader only reads the default export and needs an - * id plus a setup()/effect() function; the old named export had neither. */ +/* #2077/#2089/#2204: the two OpenCode runtimes read different entries of the + * default export. 1.18.x dispatches the hooks returned by `server(ctx)`; 2.x + * ignores `server` and calls `setup(ctx)`, whose context provides the tool and + * session hook domains. Registrations fail open per domain, so the matching + * server() hook yields once its domain is registered. The post-compaction note + * goes through a keyed session instruction (a entry that survives + * compaction), never the system prompt. */ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); ASSERT_NOT_NULL(strstr(js, "export default {")); ASSERT_NOT_NULL(strstr(js, "id: 'codebase-memory-augment'")); - /* Hooks live under server(), which the server runtime reads; setup() - * stays empty since the V2 config loader has no tool domain yet. */ - ASSERT_NOT_NULL(strstr(js, "setup() {}")); + /* OpenCode 1.18.x server runtime. */ ASSERT_NOT_NULL(strstr(js, "server: async (ctx) => {")); - ASSERT_NULL(strstr(js, "async setup(ctx) {")); + /* OpenCode 2 registers through the setup context; the domains exist on + * 2.x, so setup is no longer the empty placeholder #2089 pinned. */ + ASSERT_NOT_NULL(strstr(js, "async setup(ctx) {")); + ASSERT_NOT_NULL(strstr(js, "ctx.tool.hook('execute.after'")); + ASSERT_NOT_NULL(strstr(js, "ctx.session.hook('compaction'")); + ASSERT_NULL(strstr(js, "ctx.session.hook('context'")); + /* content is the surface the model reads; prefer it over output, and never + * synthesize content that would hide the tool's own result. */ + const char *content_first = strstr(js, "if (typeof result.content === 'string') {"); + const char *output_fallback = strstr(js, "if (typeof result.output === 'string') {"); + ASSERT_NOT_NULL(content_first); + ASSERT_NOT_NULL(output_fallback); + ASSERT_TRUE(content_first < output_fallback); + ASSERT_NULL(strstr(js, "if (result.content === undefined)")); + /* Compaction uses the durable session context channel, not the system + * prompt; older hosts without it skip reinjection. */ + ASSERT_NOT_NULL(strstr(js, "event?.sessionID")); + ASSERT_NOT_NULL(strstr(js, "ctx?.session?.instructions?.entry")); + /* The plugin instance's location is not the session's location in a + * multi-project server: resolve each session's directory. */ + ASSERT_NOT_NULL(strstr(js, "const dir = await dirFor(")); + ASSERT_NOT_NULL(strstr(js, "ctx.session.get({ sessionID })")); + ASSERT_NOT_NULL(strstr(js, "dirs.has(sessionID)")); + ASSERT_NOT_NULL(strstr(js, "if (!dir) return;")); + /* No fallback to the plugin instance's location: that is not the session's + * location in a multi-project server. */ + ASSERT_NULL(strstr(js, "ctx?.location?.directory")); + ASSERT_NOT_NULL(strstr(js, "key: 'codebase-memory-session-note'")); + ASSERT_NULL(strstr(js, "event.system.push")); + ASSERT_NULL(strstr(js, "pendingReinject")); + /* Registrations fail open per domain; the server() hooks yield per domain. */ + ASSERT_NOT_NULL(strstr(js, "toolHooksRegistered = true;")); + ASSERT_NOT_NULL(strstr(js, "sessionHooksRegistered = true;")); + ASSERT_NOT_NULL(strstr(js, "if (toolHooksRegistered) return;")); + ASSERT_NOT_NULL(strstr(js, "if (sessionHooksRegistered) return;")); + ASSERT_NOT_NULL(strstr(js, "catch {")); + ASSERT_NOT_NULL(strstr(js, "event.result = appendResult(event.result, extra);")); + /* The empty setup placeholder and the old named export must both be gone. */ + ASSERT_NULL(strstr(js, "setup() {}")); ASSERT_NULL(strstr(js, "export const CodebaseMemory")); free(js); PASS(); From c0a8ed8b938fc22bf53f1c0543a6fbf7e8b81b9c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 01:07:01 +0100 Subject: [PATCH 85/97] 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 023a963af3..4c715661ce 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 b73946cf39..8927212287 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 86/97] 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 4c715661ce..978fb5b710 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 27cc233a5e..b7b6c4dc86 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 6c111ca2db..54184ea09f 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 87/97] 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 1c4fb7194e..1a38c6f51b 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 3a476e4b83..46b1b51dcd 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 978fb5b710..03345da9f6 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 b7b6c4dc86..4c47fc67d2 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 54184ea09f..98b1a6e210 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 8927212287..4b67ea2008 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,10 +3236,93 @@ 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]); + cbm_gbuf_free(graphs[1]); + th_rmtree(tmpdir); + ASSERT_TRUE(all); + PASS(); +} + +/* A type extracted with no namespace is global only if its file parsed + * cleanly. The vendored grammar does not parse a `ref partial struct` with + * members (reference assemblies are full of them), and error recovery + * dissolves the namespace around it, so the static class after it surfaces + * with no namespace. Read as global, it outranked the compilation-unit + * `using` and took every `Gadget.Spin()` call from the real, visible type; + * on dotnet/runtime that moved 5,313 CALLS from implementation files into + * reference assemblies. The damaged file is registered FIRST so neither the + * old registry order nor the global rank can explain a pass. */ +TEST(parallel_csharp_parse_damaged_namespace_is_not_global) { + static const char damaged_src[] = "namespace Acme.Lib\n" + "{\n" + " public ref partial struct Handler\n" + " {\n" + " public void Append(string value) { }\n" + " }\n" + " public static partial class Gadget {\n" + " public static int Spin() => 9;\n" + " }\n" + "}\n"; + static const char real_src[] = "namespace Acme.Lib;\n" + "public static class Gadget {\n" + " public static int Spin() => 1;\n" + "}\n"; + static const char user_src[] = "using Acme.Lib;\n" + "namespace Acme.App;\n" + "public class GadgetUser {\n" + " public int Run() {\n" + " return Gadget.Spin();\n" + " }\n" + "}\n"; + const char *project = "cbm_cs_damaged"; + static const char *rels[] = {"src/Ref/Lib.cs", "src/Lib/Gadget.cs", "src/App/GadgetUser.cs"}; + const char *srcs[] = {damaged_src, real_src, user_src}; + enum { N_FILES = 3 }; + + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_damaged_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("mkdtemp failed"); + } + char paths[N_FILES][512]; + cbm_file_info_t files[N_FILES] = {0}; + for (int i = 0; i < N_FILES; i++) { + snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]); + if (th_write_file(paths[i], srcs[i]) != 0) { + th_rmtree(tmpdir); + FAIL("failed to write C# damaged-namespace fixture"); } + files[i].path = paths[i]; + files[i].rel_path = (char *)rels[i]; + files[i].language = CBM_LANG_CSHARP; + } + + cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL}; + g_harness_cs_registry = true; + graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2); + g_harness_cs_registry = false; + ASSERT_NOT_NULL(graphs[0]); + ASSERT_NOT_NULL(graphs[1]); + + const char *real_spin = "cbm_cs_damaged.src.Lib.Gadget.Gadget.Spin"; + const char *damaged_spin = "cbm_cs_damaged.src.Ref.Lib.Gadget.Spin"; + bool ok[2][2]; + for (int g = 0; g < 2; g++) { + ok[g][0] = has_edge_from_callable_to_qn(graphs[g], "GadgetUser.Run", real_spin, "CALLS"); + ok[g][1] = !has_edge_from_callable_to_qn(graphs[g], "GadgetUser.Run", damaged_spin, + "CALLS"); + } + bool all = ok[0][0] && ok[0][1] && ok[1][0] && ok[1][1]; + if (!all) { + printf(" C# damaged-namespace diagnostic: sequential real=%d no_damaged=%d " + "parallel real=%d no_damaged=%d\n", + ok[0][0], ok[0][1], ok[1][0], ok[1][1]); } cbm_gbuf_free(graphs[0]); cbm_gbuf_free(graphs[1]); @@ -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 88/97] 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 03345da9f6..266b846a3a 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 98b1a6e210..c6a58c474b 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); } From 339cd516608a8c8434aa83f81f7fb79a3e17c4c6 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 09:23:09 +0100 Subject: [PATCH 89/97] ci: fail fast when an apt mirror stalls On 2026-11-01 two Linux jobs hung in `sudo apt-get update` right after "Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease" until their job limit; the build never started in either: - PR 2641, test-tsan x86 (run 41251045506, job 124688326533): the "Install deps (Ubuntu)" step ran 2 h until the 120-minute job limit. - PR 2395, test-unix gcc x86 (run 41256855177, job 124705168264): the same step ran 4 h until the 240-minute job limit. Both logs show the azure mirror ignored after about 15 s, the fallback to archive.ubuntu.com, and then no output until the cancel. The default apt http timeout (120 s) did not end the stall. Rule applied to every workflow step whose run block calls apt: - A step-level timeout-minutes. 10 for steps that only provision packages (green runs on 2026-11-01 and the last green dry run: 5 to 76 s); 20 for the three pr.yml steps that also run scripts/build.sh (green: 246 to 331 s). A stalled mirror now fails the step in minutes instead of holding the runner until the job limit. - `-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30` on every apt-get update and install, with the -o options before the subcommand as in test-infrastructure/Dockerfile.msan. Nothing else changes: no action, runner, package or step-order changes. Signed-off-by: Martin Vogel --- .github/workflows/_build.yml | 6 ++++-- .github/workflows/_lint.yml | 16 ++++++++++------ .github/workflows/_memwaste.yml | 9 ++++++--- .github/workflows/_smoke.yml | 3 ++- .github/workflows/_soak.yml | 9 ++++++--- .github/workflows/_test.yml | 14 +++++++++----- .github/workflows/bug-repro.yml | 3 ++- .github/workflows/cache-warm.yml | 3 ++- .github/workflows/codeql.yml | 3 ++- .github/workflows/dry-run.yml | 3 ++- .github/workflows/fast-repro.yml | 3 ++- .github/workflows/nightly-soak.yml | 3 ++- .github/workflows/pr.yml | 12 ++++++++---- .github/workflows/smoke.yml | 3 ++- .github/workflows/test-impact-artifact.yml | 7 ++++--- 15 files changed, 63 insertions(+), 34 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 43c52b052a..dbaf089869 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -66,7 +66,8 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -250,7 +251,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 741e3c438e..b55b54d255 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -37,14 +37,16 @@ jobs: run: bash scripts/check-no-test-skips.sh - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev cmake + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev cmake - name: Install LLVM 20 + timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-20 main" | sudo tee /etc/apt/sources.list.d/llvm-20.list - sudo apt-get update - sudo apt-get install -y clang-format-20 + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-format-20 - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: cppcheck-cache @@ -81,14 +83,16 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - name: Install LLVM 21 (pinned analyzer toolchain) + timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get update - sudo apt-get install -y clang-tidy-21 + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-tidy-21 - name: Memory-analyzer gate run: scripts/ci/lint-mem.sh clang-tidy-21 diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index 09deb0c37e..a153424d7e 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -37,7 +37,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm - name: Event lane on this repository run: | # `cmd | tee` reports TEE's status: without this a failing lane @@ -66,7 +67,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm - name: CPU scaling lane (src/ at 2x against 4x) # Report-only: the script exits 1 when it flags a super-linear function. continue-on-error: true @@ -93,7 +95,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm - name: Access lane on this repository run: | # `cmd | tee` reports TEE's status: without this a failing lane diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 96c47d44f1..c7b32d5a38 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -144,8 +144,9 @@ jobs: - name: ClamAV scan (Linux) if: startsWith(matrix.os, 'ubuntu') + timeout-minutes: 10 run: | - sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1 + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update -qq && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y -qq clamav > /dev/null 2>&1 # apt auto-starts the clamav-freshclam daemon, which holds a lock on # freshclam's log/db; stop it so the manual freshclam below can run # (else: "Failed to lock the log file ... Resource temporarily unavailable"). diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index 63c3ec51e7..d7cfe0e3f9 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -66,7 +66,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git - name: Build if: ${{ !inputs.use_release_artifacts }} run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} @@ -283,7 +284,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install soak dependencies - run: sudo apt-get update && sudo apt-get install -y python3 git + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y python3 git - name: Download selected portable release container uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -356,7 +358,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git - name: Build (ASan) run: | SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 55a8bf6085..ffd870a788 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -169,7 +169,8 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache # Ubuntu 23.10+ ships kernel.apparmor_restrict_unprivileged_userns=1, which # blocks unshare(CLONE_NEWUSER) for unprivileged users. Without this, the @@ -370,14 +371,16 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache - name: Install LLVM 21 (pinned diagnostic toolchain) + timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get update - sudo apt-get install -y clang-21 + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-21 - name: Compiler cache (content-verified, restore) uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -528,7 +531,8 @@ jobs: - name: Install deps (Ubuntu) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev ccache + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang zlib1g-dev ccache - name: Install deps (macOS) if: runner.os == 'macOS' diff --git a/.github/workflows/bug-repro.yml b/.github/workflows/bug-repro.yml index e3e1a647a6..762eddfaee 100644 --- a/.github/workflows/bug-repro.yml +++ b/.github/workflows/bug-repro.yml @@ -55,7 +55,8 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - name: Run bug-reproduction board if: ${{ github.event_name == 'push' || inputs.platforms == 'all' || inputs.platforms == matrix.group }} diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index bca57b23b7..d898e0c6ab 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -36,7 +36,8 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache - name: Install ccache (macOS) if: startsWith(matrix.os, 'macos') diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f5cfb59d6d..38375d4889 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -95,7 +95,8 @@ jobs: - name: Install build dependencies if: ${{ matrix.language == 'c-cpp' }} - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev # PR c-cpp: compile the third-party objects (tree-sitter grammars, # sqlite, mimalloc, lz4, zstd, the tree-sitter runtime: ~1.3 GB of diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 4634240929..1178761fa0 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -142,7 +142,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev - name: Fuzz smoke (extract, cypher, config) run: scripts/fuzz.sh all --runs 20000 --seed 1 - name: Upload reproducers diff --git a/.github/workflows/fast-repro.yml b/.github/workflows/fast-repro.yml index 5f38c17729..eab0fabb26 100644 --- a/.github/workflows/fast-repro.yml +++ b/.github/workflows/fast-repro.yml @@ -26,7 +26,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - name: test-repro (single platform, ASan; CBM_REPRO_ONLY filters suites) env: # Optionally narrow to specific suites for a fast targeted check, e.g. diff --git a/.github/workflows/nightly-soak.yml b/.github/workflows/nightly-soak.yml index 2bd5bc7e86..9aa3949e97 100644 --- a/.github/workflows/nightly-soak.yml +++ b/.github/workflows/nightly-soak.yml @@ -45,7 +45,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev - name: Fuzz exploration (30 min per target) run: scripts/fuzz.sh all --max-total-time 1800 --seed 0 - name: Upload reproducers diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index aade2b93da..d925e57cdd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -67,8 +67,9 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Build this checkout's engine + timeout-minutes: 20 run: | - sudo apt-get update && sudo apt-get install -y zlib1g-dev + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev scripts/build.sh CC=gcc CXX=g++ - name: Select the tests id: select @@ -211,7 +212,8 @@ jobs: - name: Install deps (Ubuntu) if: matrix.os == 'ubuntu-latest' - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache - name: Install ccache (macOS) if: matrix.os == 'macos-15' @@ -318,8 +320,9 @@ jobs: run: scripts/ci/test-impact-shadow.sh --fetch-release "$RUNNER_TEMP/cbm-bin" - name: Build the binary from this checkout (download failed) if: ${{ steps.release.outcome == 'failure' }} + timeout-minutes: 20 run: | - sudo apt-get update && sudo apt-get install -y zlib1g-dev + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev scripts/build.sh CC=gcc CXX=g++ mkdir -p "$RUNNER_TEMP/cbm-bin" cp build/c/codebase-memory-mcp "$RUNNER_TEMP/cbm-bin/" @@ -329,8 +332,9 @@ jobs: # released binary predates it, so the checkout is built either way. - name: Build this checkout's engine if: ${{ steps.release.outcome != 'failure' }} + timeout-minutes: 20 run: | - sudo apt-get update && sudo apt-get install -y zlib1g-dev + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev scripts/build.sh CC=gcc CXX=g++ - name: Select with the team artifact (engine shadow) env: diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 2de8e1b8e4..d130aed695 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -51,7 +51,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git + timeout-minutes: 10 + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git - name: Build (production path with explicit test seams) run: scripts/build.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} TEST_SEAMS=1 - name: Smoke invariants diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml index 4db216ecde..4174a7e1d8 100644 --- a/.github/workflows/test-impact-artifact.yml +++ b/.github/workflows/test-impact-artifact.yml @@ -44,12 +44,13 @@ jobs: persist-credentials: false - name: Install build deps and LLVM 21 (coverage toolchain) + timeout-minutes: 10 run: | - sudo apt-get update && sudo apt-get install -y zlib1g-dev + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get update - sudo apt-get install -y clang-21 llvm-21 libclang-rt-21-dev + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-21 llvm-21 libclang-rt-21-dev - name: Build the production binary and the coverage runner run: | From f531be2500f46a3af132287a6fa9011e0ac27328 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 09:33:03 +0100 Subject: [PATCH 90/97] fix(cli): keep the OpenCode 2 compaction note inside the compaction The OpenCode 2 compaction hook receives the compaction request, whose messages are the input the summary is built from. Add the post-compaction session note there as a text message, mirroring the 1.18.x path that pushes it into output.context. Session instruction entries are rendered into the system array of every later request, so the note no longer goes through ctx.session.instructions.entry, and event.system stays untouched. Registration and the push still fail open. Pin it in a new agent_clients test: the push sits inside the compaction registration, carries a user text part, is guarded by an array check, and the generated module names neither a system property nor an instruction entry. The #2077 test drops the assertions that pinned the old channel. Refs #2204 Signed-off-by: Martin Vogel --- src/cli/client_adapter.c | 26 ++++++++++----------- src/cli/client_adapter.h | 4 ++-- tests/test_agent_clients.c | 47 ++++++++++++++++++++++++++++++++------ 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index 60cb0cd3d7..e5263fc530 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -428,7 +428,7 @@ char *cbm_client_adapter_opencode(const char *binary_path) { "// context surfaces other clients get through hook configuration: graph\n" "// lookup after grep/glob, index-coverage notes after read, session-start\n" "// tier routing (carried on the first tool result of each session), and\n" - "// reinjection after compaction through the session context channel.\n" + "// reinjection after compaction inside the compaction request itself.\n" "//\n" "// Two runtimes load this module:\n" "// - OpenCode 1.18.x dispatches the hooks returned by server(ctx).\n" @@ -642,26 +642,24 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " // Fail open: the server() tool hook stays active.\n" " }\n" " }\n" - " // OpenCode 2 compaction: persist the note as a keyed session\n" - " // instruction (a entry) instead of editing the system\n" - " // prompt; entries survive compaction. Hosts without the channel\n" - " // skip reinjection rather than failing the plugin.\n" + " // OpenCode 2 compaction: the hook receives the compaction request, and\n" + " // its messages are the input the summary is built from. The note is\n" + " // added there as a text message, as the server() path adds it to\n" + " // output.context, so it stays inside the compaction and out of the\n" + " // system prompt. Hosts without the hook or the array skip reinjection\n" + " // rather than failing the plugin.\n" " if (ctx?.session?.hook) {\n" " try {\n" " await ctx.session.hook('compaction', async (event) => {\n" - " const sessionID = event?.sessionID;\n" - " if (typeof sessionID !== 'string' || sessionID === '') return;\n" - " const entry = ctx?.session?.instructions?.entry;\n" - " if (typeof entry?.put !== 'function') return;\n" - " const dir = await dirFor(sessionID);\n" + " if (!Array.isArray(event?.messages)) return;\n" + " const dir = await dirFor(event?.sessionID);\n" " if (!dir) return;\n" " const note = await lifecycle(dir);\n" " if (!note) return;\n" " try {\n" - " await entry.put({\n" - " sessionID,\n" - " key: 'codebase-memory-session-note',\n" - " value: note,\n" + " event.messages.push({\n" + " role: 'user',\n" + " content: [{ type: 'text', text: note }],\n" " });\n" " } catch {\n" " // Fail open: reinjection is best-effort.\n" diff --git a/src/cli/client_adapter.h b/src/cli/client_adapter.h index bbe197d89e..3c8fc39023 100644 --- a/src/cli/client_adapter.h +++ b/src/cli/client_adapter.h @@ -57,8 +57,8 @@ char *cbm_client_adapter_pi(const char *binary_path); * server() path and recorded here so it is not rediscovered as a mystery. * OpenCode 2 ignores `server` and calls `setup(ctx)`, which registers * `execute.after` and `compaction` on the context domains. Each registration - * fails open, and the post-compaction note is stored as a keyed session - * instruction (`` entry) instead of the system prompt. + * fails open, and the post-compaction note is added as a text message to the + * compaction request's `messages` (the summary input), never to `system`. * * Returns NULL on allocation failure or when binary_path is NULL/empty. */ char *cbm_client_adapter_opencode(const char *binary_path); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index a3afe14944..14a61e9c52 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1347,8 +1347,8 @@ TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) { * ignores `server` and calls `setup(ctx)`, whose context provides the tool and * session hook domains. Registrations fail open per domain, so the matching * server() hook yields once its domain is registered. The post-compaction note - * goes through a keyed session instruction (a entry that survives - * compaction), never the system prompt. */ + * goes into the compaction request's messages, never the system prompt (pinned + * by the compaction test below). */ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); @@ -1370,10 +1370,8 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { ASSERT_NOT_NULL(output_fallback); ASSERT_TRUE(content_first < output_fallback); ASSERT_NULL(strstr(js, "if (result.content === undefined)")); - /* Compaction uses the durable session context channel, not the system - * prompt; older hosts without it skip reinjection. */ + /* The V2 hooks read the session id with optional chaining (fail open). */ ASSERT_NOT_NULL(strstr(js, "event?.sessionID")); - ASSERT_NOT_NULL(strstr(js, "ctx?.session?.instructions?.entry")); /* The plugin instance's location is not the session's location in a * multi-project server: resolve each session's directory. */ ASSERT_NOT_NULL(strstr(js, "const dir = await dirFor(")); @@ -1383,8 +1381,6 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { /* No fallback to the plugin instance's location: that is not the session's * location in a multi-project server. */ ASSERT_NULL(strstr(js, "ctx?.location?.directory")); - ASSERT_NOT_NULL(strstr(js, "key: 'codebase-memory-session-note'")); - ASSERT_NULL(strstr(js, "event.system.push")); ASSERT_NULL(strstr(js, "pendingReinject")); /* Registrations fail open per domain; the server() hooks yield per domain. */ ASSERT_NOT_NULL(strstr(js, "toolHooksRegistered = true;")); @@ -1400,6 +1396,42 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { PASS(); } +/* #2204: on OpenCode 2 the post-compaction note stays inside the compaction, + * at the level the 1.18.x path uses (output.context). The `compaction` hook + * receives the compaction request, whose `messages` are the input the summary + * is built from, so the note is pushed there as a text message. It must never + * reach the system prompt: not through `event.system`, and not through a + * session instruction entry, which OpenCode renders into the system array of + * every later request. */ +TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages) { + char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); + ASSERT_NOT_NULL(js); + const char *hook = strstr(js, "ctx.session.hook('compaction'"); + const char *push = strstr(js, "event.messages.push({"); + const char *registered = strstr(js, "sessionHooksRegistered = true;"); + ASSERT_NOT_NULL(hook); + ASSERT_NOT_NULL(push); + ASSERT_NOT_NULL(registered); + /* The push lives inside the compaction registration. */ + ASSERT_TRUE(hook < push); + ASSERT_TRUE(push < registered); + /* A text message that carries the note. */ + const char *role = strstr(push, "role: 'user',"); + const char *text = strstr(push, "content: [{ type: 'text', text: note }],"); + ASSERT_NOT_NULL(role); + ASSERT_NOT_NULL(text); + ASSERT_TRUE(role < registered); + ASSERT_TRUE(text < registered); + /* Fail open: a host whose event has no messages array skips reinjection. */ + ASSERT_NOT_NULL(strstr(js, "if (!Array.isArray(event?.messages)) return;")); + /* Never the system prompt, and no session instruction entry. */ + ASSERT_NULL(strstr(js, ".system")); + ASSERT_NULL(strstr(js, "instructions")); + ASSERT_NULL(strstr(js, "entry.put")); + free(js); + PASS(); +} + /* augment() is awaited inline inside 'tool.execute.after', so a hook-augment * that never exits would stall every grep/glob/read completion in the host * session until the agent is restarted. The generated plugin must bound the @@ -1472,6 +1504,7 @@ SUITE(agent_clients) { RUN_TEST(client_adapter_opencode_sends_the_required_hook_event); RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction); RUN_TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077); + RUN_TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages); RUN_TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child); RUN_TEST(client_adapter_rejects_missing_binary_path); } From d3d0bc8452eae08e067e4d4be604c9ce72a69984 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 09:33:17 +0100 Subject: [PATCH 91/97] fix(cli): never create note-only content for an empty OpenCode 2 result Tool.Result allows content: [], and for it the array branch of appendResult pushed a text part that held only the session note, so the note became the whole content of the tool result. Add a text part only when the array already holds a part; an empty array now falls through to the string output branch, or the result is returned unchanged. Pin it in a new agent_clients test: the part-adding push sits inside the non-empty array branch, ahead of the string output fallback, and no unguarded array branch is left. Refs #2204 Signed-off-by: Martin Vogel --- src/cli/client_adapter.c | 8 ++++++-- tests/test_agent_clients.c | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index e5263fc530..e630193608 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -491,14 +491,18 @@ char *cbm_client_adapter_opencode(const char *binary_path) { * string/parts surface the model reads, output is the tool's schema-typed * value). Append to content first, then a string output; if neither text * surface exists, leave the result untouched so the tool's own output is - * never hidden. The caller assigns the returned result onto event.result. */ + * never hidden. A parts array gets a new text part only when it already + * holds a part: `content: []` is a valid result, and a part added there + * would be the note alone, so an empty array falls through to the string + * output (or the unchanged result). The caller assigns the returned result + * onto event.result. */ sb_append(&sb, "function appendResult(result, extra) {\n" " if (!result || typeof result !== 'object') return result;\n" " if (typeof result.content === 'string') {\n" " return { ...result, content: result.content + '\\n' + extra };\n" " }\n" - " if (Array.isArray(result.content)) {\n" + " if (Array.isArray(result.content) && result.content.length > 0) {\n" " const content = result.content.slice();\n" " for (let i = content.length - 1; i >= 0; i--) {\n" " if (content[i] && content[i].type === 'text') {\n" diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 14a61e9c52..381bfda68e 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1432,6 +1432,31 @@ TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_message PASS(); } +/* #2204: Tool.Result allows `content: []`. appendResult adds a text part to a + * parts array only when the array already holds a part; an empty array falls + * through to the string `output` (or the unchanged result), so the note never + * becomes the whole content of a tool result. */ +TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content) { + char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); + ASSERT_NOT_NULL(js); + const char *parts = + strstr(js, "if (Array.isArray(result.content) && result.content.length > 0) {"); + const char *add_part = strstr(js, "content.push({ type: 'text', text: extra });"); + const char *output_fallback = strstr(js, "if (typeof result.output === 'string') {"); + ASSERT_NOT_NULL(parts); + ASSERT_NOT_NULL(add_part); + ASSERT_NOT_NULL(output_fallback); + /* The only part-adding push sits inside the non-empty branch; the string + * output fallback, where `content: []` lands, comes after it. */ + ASSERT_TRUE(parts < add_part); + ASSERT_TRUE(add_part < output_fallback); + ASSERT_NULL(strstr(add_part + 1, "content.push(")); + /* No unguarded array branch is left. */ + ASSERT_NULL(strstr(js, "if (Array.isArray(result.content)) {")); + free(js); + PASS(); +} + /* augment() is awaited inline inside 'tool.execute.after', so a hook-augment * that never exits would stall every grep/glob/read completion in the host * session until the agent is restarted. The generated plugin must bound the @@ -1505,6 +1530,7 @@ SUITE(agent_clients) { RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction); RUN_TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077); RUN_TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages); + RUN_TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content); RUN_TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child); RUN_TEST(client_adapter_rejects_missing_binary_path); } From a545a0aa8cebade912ff2990122e9022091dc8a7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 10:08:28 +0100 Subject: [PATCH 92/97] ci: fail apt-get update on any fetch error apt-get update exits 0 when index fetches fail: it only prints "W: Some index files failed to download. They have been ignored, or old ones used instead." and the job breaks later at install time with a misleading "Unable to locate package", or installs from stale lists. Add --error-on=any to every apt-get update this branch hardens, placed after the -o options as in test-infrastructure/Dockerfile.msan, so the update step itself fails when an index cannot be fetched. Proof in ubuntu:24.04 (apt 2.8.3) and ubuntu:22.04 (apt 2.4.14) containers with a runner-shaped mirror+file:/etc/apt/apt-mirrors.txt source and the same -o options CI passes: - every mirror unreachable: exit 100 with the flag (E: lines), exit 0 without it (W: lines, zero Packages lists), on both apt versions - first mirror refused or stalled (30 s connect timeout) and the real archive second: the Ign: failover lines do not trip the flag, exit 0 with every index fetched (measured with Acquire::Retries=0) - the image sources with the flag: exit 0 and no Ign/W/E lines, so the option is understood by apt 2.4 and 2.8 - green hosted-runner logs (ubuntu-24.04 and ubuntu-22.04 legs) show no Ign/Err/W/E lines in the update step With Acquire::Retries=3 a mirror failover leaves apt idle after the InRelease failover, with or without the flag and also for a plain apt-get update; that apt behaviour predates this change and is unchanged by it. Signed-off-by: Martin Vogel --- .github/workflows/_build.yml | 4 ++-- .github/workflows/_lint.yml | 8 ++++---- .github/workflows/_memwaste.yml | 6 +++--- .github/workflows/_smoke.yml | 2 +- .github/workflows/_soak.yml | 6 +++--- .github/workflows/_test.yml | 8 ++++---- .github/workflows/bug-repro.yml | 2 +- .github/workflows/cache-warm.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/dry-run.yml | 2 +- .github/workflows/fast-repro.yml | 2 +- .github/workflows/nightly-soak.yml | 2 +- .github/workflows/pr.yml | 8 ++++---- .github/workflows/smoke.yml | 2 +- .github/workflows/test-impact-artifact.yml | 4 ++-- 15 files changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index dbaf089869..069e987745 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -67,7 +67,7 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -252,7 +252,7 @@ jobs: - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index b55b54d255..750b6b90cd 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -38,14 +38,14 @@ jobs: - name: Install build deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev cmake + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev cmake - name: Install LLVM 20 timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-20 main" | sudo tee /etc/apt/sources.list.d/llvm-20.list - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-format-20 - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -84,14 +84,14 @@ jobs: - name: Install build deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - name: Install LLVM 21 (pinned analyzer toolchain) timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-tidy-21 - name: Memory-analyzer gate diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index a153424d7e..3adbaac69e 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -38,7 +38,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm - name: Event lane on this repository run: | # `cmd | tee` reports TEE's status: without this a failing lane @@ -68,7 +68,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm - name: CPU scaling lane (src/ at 2x against 4x) # Report-only: the script exits 1 when it flags a super-linear function. continue-on-error: true @@ -96,7 +96,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm - name: Access lane on this repository run: | # `cmd | tee` reports TEE's status: without this a failing lane diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index c7b32d5a38..67532e6bf6 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -146,7 +146,7 @@ jobs: if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update -qq && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y -qq clamav > /dev/null 2>&1 + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update -qq && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y -qq clamav > /dev/null 2>&1 # apt auto-starts the clamav-freshclam daemon, which holds a lock on # freshclam's log/db; stop it so the manual freshclam below can run # (else: "Failed to lock the log file ... Resource temporarily unavailable"). diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index d7cfe0e3f9..89921b143f 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -67,7 +67,7 @@ jobs: - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git - name: Build if: ${{ !inputs.use_release_artifacts }} run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} @@ -285,7 +285,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install soak dependencies timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y python3 git + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y python3 git - name: Download selected portable release container uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -359,7 +359,7 @@ jobs: - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git - name: Build (ASan) run: | SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index ffd870a788..64f0bdc8c8 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -170,7 +170,7 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache # Ubuntu 23.10+ ships kernel.apparmor_restrict_unprivileged_userns=1, which # blocks unshare(CLONE_NEWUSER) for unprivileged users. Without this, the @@ -372,14 +372,14 @@ jobs: - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache - name: Install LLVM 21 (pinned diagnostic toolchain) timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-21 - name: Compiler cache (content-verified, restore) @@ -532,7 +532,7 @@ jobs: - name: Install deps (Ubuntu) if: runner.os == 'Linux' timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang zlib1g-dev ccache + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang zlib1g-dev ccache - name: Install deps (macOS) if: runner.os == 'macOS' diff --git a/.github/workflows/bug-repro.yml b/.github/workflows/bug-repro.yml index 762eddfaee..20f0845379 100644 --- a/.github/workflows/bug-repro.yml +++ b/.github/workflows/bug-repro.yml @@ -56,7 +56,7 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - name: Run bug-reproduction board if: ${{ github.event_name == 'push' || inputs.platforms == 'all' || inputs.platforms == matrix.group }} diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index d898e0c6ab..68558c87b1 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -37,7 +37,7 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache - name: Install ccache (macOS) if: startsWith(matrix.os, 'macos') diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 38375d4889..9e775a99d8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -96,7 +96,7 @@ jobs: - name: Install build dependencies if: ${{ matrix.language == 'c-cpp' }} timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev # PR c-cpp: compile the third-party objects (tree-sitter grammars, # sqlite, mimalloc, lz4, zstd, the tree-sitter runtime: ~1.3 GB of diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 1178761fa0..63804e0549 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -143,7 +143,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev - name: Fuzz smoke (extract, cypher, config) run: scripts/fuzz.sh all --runs 20000 --seed 1 - name: Upload reproducers diff --git a/.github/workflows/fast-repro.yml b/.github/workflows/fast-repro.yml index eab0fabb26..a0220e2322 100644 --- a/.github/workflows/fast-repro.yml +++ b/.github/workflows/fast-repro.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - name: test-repro (single platform, ASan; CBM_REPRO_ONLY filters suites) env: # Optionally narrow to specific suites for a fast targeted check, e.g. diff --git a/.github/workflows/nightly-soak.yml b/.github/workflows/nightly-soak.yml index 9aa3949e97..9df0b58408 100644 --- a/.github/workflows/nightly-soak.yml +++ b/.github/workflows/nightly-soak.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev - name: Fuzz exploration (30 min per target) run: scripts/fuzz.sh all --max-total-time 1800 --seed 0 - name: Upload reproducers diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d925e57cdd..c87e37e62e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -69,7 +69,7 @@ jobs: - name: Build this checkout's engine timeout-minutes: 20 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev scripts/build.sh CC=gcc CXX=g++ - name: Select the tests id: select @@ -213,7 +213,7 @@ jobs: - name: Install deps (Ubuntu) if: matrix.os == 'ubuntu-latest' timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache - name: Install ccache (macOS) if: matrix.os == 'macos-15' @@ -322,7 +322,7 @@ jobs: if: ${{ steps.release.outcome == 'failure' }} timeout-minutes: 20 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev scripts/build.sh CC=gcc CXX=g++ mkdir -p "$RUNNER_TEMP/cbm-bin" cp build/c/codebase-memory-mcp "$RUNNER_TEMP/cbm-bin/" @@ -334,7 +334,7 @@ jobs: if: ${{ steps.release.outcome != 'failure' }} timeout-minutes: 20 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev scripts/build.sh CC=gcc CXX=g++ - name: Select with the team artifact (engine shadow) env: diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index d130aed695..32a79f7df3 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -52,7 +52,7 @@ jobs: - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git + run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git - name: Build (production path with explicit test seams) run: scripts/build.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} TEST_SEAMS=1 - name: Smoke invariants diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml index 4174a7e1d8..487ea59864 100644 --- a/.github/workflows/test-impact-artifact.yml +++ b/.github/workflows/test-impact-artifact.yml @@ -46,10 +46,10 @@ jobs: - name: Install build deps and LLVM 21 (coverage toolchain) timeout-minutes: 10 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-21 llvm-21 libclang-rt-21-dev - name: Build the production binary and the coverage runner From e3a9fa34edfbda173c344c10fe64ba10e2fdbc8f Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 12:32:45 +0100 Subject: [PATCH 93/97] ci: let apt fail over between mirrors instead of hanging The hosted Ubuntu x64 runners read the archive through mirror+file:/etc/apt/apt-mirrors.txt. With Acquire::Retries above 0 (including the apt default of 3), a failover from a dead first entry of that list (connection refused, DNS failure or a stalled host) to the next entry goes idle: apt-get fetches a few InRelease files from the second mirror and then waits until the step timeout. With Acquire::Retries=0 the same failover completes. Seen on noble (apt 2.8.3) and jammy (apt 2.4.14). Every apt step this branch hardened now picks the retry count at runtime, applied to both update and install: 0 when the runner has the mirror list (moving to the next mirror is the retry), 3 otherwise (arm64 runners and any host with plain sources keep their retries). --error-on=any, the http and https timeouts and the step timeouts are unchanged. Proof: the step text extracted from the edited _build.yml (update plus install of zlib1g-dev), run under bash -e in ubuntu:24.04 and ubuntu:22.04 containers with runner-shaped mirror lists: - first mirror refused: exit 0 in 8 s / 9 s, every index and deb fetched from the second mirror - first mirror stalled (192.0.2.1): exit 0 in 68 s / 69 s, one 30 s timeout each for update and install, then the second mirror - healthy first mirror: exit 0 in 7 s / 9 s - no mirror list, plain sources: the prefix keeps Retries=3, exit 0 in 8 s / 7 s - every mirror dead: update exits 100 in under 1 s, install skipped - revert check, the previous step text with a dead first mirror (refused or stalled): idle with no Packages index at the 180 s cap on both releases Signed-off-by: Martin Vogel --- .github/workflows/_build.yml | 4 ++-- .github/workflows/_lint.yml | 12 ++++++------ .github/workflows/_memwaste.yml | 6 +++--- .github/workflows/_smoke.yml | 2 +- .github/workflows/_soak.yml | 6 +++--- .github/workflows/_test.yml | 10 +++++----- .github/workflows/bug-repro.yml | 2 +- .github/workflows/cache-warm.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/dry-run.yml | 2 +- .github/workflows/fast-repro.yml | 2 +- .github/workflows/nightly-soak.yml | 2 +- .github/workflows/pr.yml | 8 ++++---- .github/workflows/smoke.yml | 2 +- .github/workflows/test-impact-artifact.yml | 6 +++--- 15 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 069e987745..152cca7376 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -67,7 +67,7 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -252,7 +252,7 @@ jobs: - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 750b6b90cd..bddcee7df2 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -38,15 +38,15 @@ jobs: - name: Install build deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev cmake + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev cmake - name: Install LLVM 20 timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-20 main" | sudo tee /etc/apt/sources.list.d/llvm-20.list - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-format-20 + APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update + sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-format-20 - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: cppcheck-cache @@ -84,15 +84,15 @@ jobs: - name: Install build deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - name: Install LLVM 21 (pinned analyzer toolchain) timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-tidy-21 + APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update + sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-tidy-21 - name: Memory-analyzer gate run: scripts/ci/lint-mem.sh clang-tidy-21 diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index 3adbaac69e..72701ca7ad 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -38,7 +38,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm - name: Event lane on this repository run: | # `cmd | tee` reports TEE's status: without this a failing lane @@ -68,7 +68,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm - name: CPU scaling lane (src/ at 2x against 4x) # Report-only: the script exits 1 when it flags a super-linear function. continue-on-error: true @@ -96,7 +96,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 clang llvm - name: Access lane on this repository run: | # `cmd | tee` reports TEE's status: without this a failing lane diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 67532e6bf6..ed1022b718 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -146,7 +146,7 @@ jobs: if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update -qq && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y -qq clamav > /dev/null 2>&1 + APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update -qq && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y -qq clamav > /dev/null 2>&1 # apt auto-starts the clamav-freshclam daemon, which holds a lock on # freshclam's log/db; stop it so the manual freshclam below can run # (else: "Failed to lock the log file ... Resource temporarily unavailable"). diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index 89921b143f..39f9e76b1d 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -67,7 +67,7 @@ jobs: - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git - name: Build if: ${{ !inputs.use_release_artifacts }} run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} @@ -285,7 +285,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install soak dependencies timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y python3 git + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y python3 git - name: Download selected portable release container uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -359,7 +359,7 @@ jobs: - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git - name: Build (ASan) run: | SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 64f0bdc8c8..501a82b205 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -170,7 +170,7 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache # Ubuntu 23.10+ ships kernel.apparmor_restrict_unprivileged_userns=1, which # blocks unshare(CLONE_NEWUSER) for unprivileged users. Without this, the @@ -372,15 +372,15 @@ jobs: - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache - name: Install LLVM 21 (pinned diagnostic toolchain) timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-21 + APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update + sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-21 - name: Compiler cache (content-verified, restore) uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -532,7 +532,7 @@ jobs: - name: Install deps (Ubuntu) if: runner.os == 'Linux' timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang zlib1g-dev ccache + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang zlib1g-dev ccache - name: Install deps (macOS) if: runner.os == 'macOS' diff --git a/.github/workflows/bug-repro.yml b/.github/workflows/bug-repro.yml index 20f0845379..3d10e42ab6 100644 --- a/.github/workflows/bug-repro.yml +++ b/.github/workflows/bug-repro.yml @@ -56,7 +56,7 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - name: Run bug-reproduction board if: ${{ github.event_name == 'push' || inputs.platforms == 'all' || inputs.platforms == matrix.group }} diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index 68558c87b1..9951cd49ed 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -37,7 +37,7 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache - name: Install ccache (macOS) if: startsWith(matrix.os, 'macos') diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9e775a99d8..6aa63a0d83 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -96,7 +96,7 @@ jobs: - name: Install build dependencies if: ${{ matrix.language == 'c-cpp' }} timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev # PR c-cpp: compile the third-party objects (tree-sitter grammars, # sqlite, mimalloc, lz4, zstd, the tree-sitter runtime: ~1.3 GB of diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 63804e0549..c582716fea 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -143,7 +143,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev - name: Fuzz smoke (extract, cypher, config) run: scripts/fuzz.sh all --runs 20000 --seed 1 - name: Upload reproducers diff --git a/.github/workflows/fast-repro.yml b/.github/workflows/fast-repro.yml index a0220e2322..be6228e484 100644 --- a/.github/workflows/fast-repro.yml +++ b/.github/workflows/fast-repro.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - name: test-repro (single platform, ASan; CBM_REPRO_ONLY filters suites) env: # Optionally narrow to specific suites for a fast targeted check, e.g. diff --git a/.github/workflows/nightly-soak.yml b/.github/workflows/nightly-soak.yml index 9df0b58408..d4357aad63 100644 --- a/.github/workflows/nightly-soak.yml +++ b/.github/workflows/nightly-soak.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev clang llvm libclang-rt-dev - name: Fuzz exploration (30 min per target) run: scripts/fuzz.sh all --max-total-time 1800 --seed 0 - name: Upload reproducers diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c87e37e62e..e20ac274af 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -69,7 +69,7 @@ jobs: - name: Build this checkout's engine timeout-minutes: 20 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev scripts/build.sh CC=gcc CXX=g++ - name: Select the tests id: select @@ -213,7 +213,7 @@ jobs: - name: Install deps (Ubuntu) if: matrix.os == 'ubuntu-latest' timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev ccache - name: Install ccache (macOS) if: matrix.os == 'macos-15' @@ -322,7 +322,7 @@ jobs: if: ${{ steps.release.outcome == 'failure' }} timeout-minutes: 20 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev scripts/build.sh CC=gcc CXX=g++ mkdir -p "$RUNNER_TEMP/cbm-bin" cp build/c/codebase-memory-mcp "$RUNNER_TEMP/cbm-bin/" @@ -334,7 +334,7 @@ jobs: if: ${{ steps.release.outcome != 'failure' }} timeout-minutes: 20 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev scripts/build.sh CC=gcc CXX=g++ - name: Select with the team artifact (engine shadow) env: diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 32a79f7df3..66e62ff542 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -52,7 +52,7 @@ jobs: - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') timeout-minutes: 10 - run: sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev python3 git - name: Build (production path with explicit test seams) run: scripts/build.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} TEST_SEAMS=1 - name: Smoke invariants diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml index 487ea59864..49f70dd673 100644 --- a/.github/workflows/test-impact-artifact.yml +++ b/.github/workflows/test-impact-artifact.yml @@ -46,11 +46,11 @@ jobs: - name: Install build deps and LLVM 21 (coverage toolchain) timeout-minutes: 10 run: | - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev + APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-21 llvm-21 libclang-rt-21-dev + sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update + sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang-21 llvm-21 libclang-rt-21-dev - name: Build the production binary and the coverage runner run: | From fb88365dc07936014c758745d013e0d72f8918bf Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 12:55:46 +0100 Subject: [PATCH 94/97] fix(cli): add no OpenCode 2 compaction note until one has run on a real host The V2 compaction hook pushed a plain object into the compaction request's event.messages. OpenCode's Message is a schema class at v2.2.5, and nobody has run this path on a real 2.x host. The try/catch only guards the array push, which cannot throw. If OpenCode rejects the object, that happens inside its own compaction, out of the plugin's reach, so an untested note could break compaction for every OpenCode 2 user. Losing the note costs far less. Remove the V2 compaction registration and the sessionHooksRegistered flag it needed. The V2 tool hook, proven on OpenCode 2.2.5, stays, and so does the 1.18.x experimental.session.compacting path. OpenCode 2 sessions still get the session note on the first tool result of a session. A later change can add a note after compaction together with a real 2.x run. The replacement test pins that the generated module registers no session hook and touches neither event.messages, a system property nor an instruction entry. Refs #2204 Signed-off-by: Martin Vogel --- README.md | 2 +- src/cli/client_adapter.c | 51 ++++++++-------------------------- src/cli/client_adapter.h | 7 +++-- tests/test_agent_clients.c | 56 ++++++++++++-------------------------- 4 files changed, 34 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index c4428e0675..48f73ec7e0 100644 --- a/README.md +++ b/README.md @@ -722,7 +722,7 @@ overwrite user-modified agents. | Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart`, `PreToolUse` graph lookup for `Bash` searches | | Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` | | Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill | -| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection | +| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection on OpenCode 1.18.x | | Antigravity | Detected | `.gemini/config/mcp_config.json` | `.gemini/GEMINI.md` | | Aider | Detected | none | `CONVENTIONS.md` via `.aider.conf.yml` | | KiloCode | Detected | `.config/kilo/kilo.jsonc` | Rule + three graph-tool subagents with deny-by-default permissions | diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index e630193608..f5747c4c80 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -428,14 +428,15 @@ char *cbm_client_adapter_opencode(const char *binary_path) { "// context surfaces other clients get through hook configuration: graph\n" "// lookup after grep/glob, index-coverage notes after read, session-start\n" "// tier routing (carried on the first tool result of each session), and\n" - "// reinjection after compaction inside the compaction request itself.\n" + "// on OpenCode 1.18.x reinjection after compaction.\n" "//\n" "// Two runtimes load this module:\n" "// - OpenCode 1.18.x dispatches the hooks returned by server(ctx).\n" "// - OpenCode 2 ignores server and calls setup(ctx), which registers the\n" - "// same behaviour on the context's tool and session hook domains. Each\n" - "// registration fails open, and the matching server() hook yields once\n" - "// its domain has registered.\n"); + "// tool hook on the context's tool domain. The registration fails open,\n" + "// and the server() tool hook yields once it has registered. OpenCode 2\n" + "// gets no note after compaction: no compaction channel has been run on\n" + "// a real 2.x host yet.\n"); sb_append(&sb, "import { spawn } from 'node:child_process';\n\n"); sb_append(&sb, "const BIN = '"); sb_append(&sb, bin); @@ -519,11 +520,10 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " return result;\n" "}\n\n"); - /* Per-domain registration flags: a host that dispatches both server() and - * setup() with domains must not append the note twice. The server() hook - * for a domain yields once setup() registered that same domain. */ - sb_append(&sb, "let toolHooksRegistered = false;\n" - "let sessionHooksRegistered = false;\n\n"); + /* A host that dispatches both server() and setup() with a tool domain must + * not append the note twice: the server() tool hook yields once setup() + * registered the tool domain. */ + sb_append(&sb, "let toolHooksRegistered = false;\n\n"); sb_append( &sb, @@ -575,7 +575,6 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " // Documented (experimental) compaction surface: output.context is the\n" " // mutable array of context strings for the rebuilt session.\n" " 'experimental.session.compacting': async (_input, output) => {\n" - " if (sessionHooksRegistered) return;\n" " const note = await lifecycle();\n" " if (note && Array.isArray(output?.context)) {\n" " output.context.push(note);\n" @@ -584,8 +583,8 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " };\n" " },\n" " // OpenCode 2 runtime: default.server is ignored and setup(ctx) registers\n" - " // the hooks on the context domains. Each registration fails open, so one\n" - " // missing domain cannot break the host or the other domain.\n" + " // the tool hook on the context's tool domain. The registration fails\n" + " // open, so a missing domain cannot break the host.\n" " async setup(ctx) {\n" " const seen = new Set();\n" " const dirs = new Map();\n" @@ -646,34 +645,6 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " // Fail open: the server() tool hook stays active.\n" " }\n" " }\n" - " // OpenCode 2 compaction: the hook receives the compaction request, and\n" - " // its messages are the input the summary is built from. The note is\n" - " // added there as a text message, as the server() path adds it to\n" - " // output.context, so it stays inside the compaction and out of the\n" - " // system prompt. Hosts without the hook or the array skip reinjection\n" - " // rather than failing the plugin.\n" - " if (ctx?.session?.hook) {\n" - " try {\n" - " await ctx.session.hook('compaction', async (event) => {\n" - " if (!Array.isArray(event?.messages)) return;\n" - " const dir = await dirFor(event?.sessionID);\n" - " if (!dir) return;\n" - " const note = await lifecycle(dir);\n" - " if (!note) return;\n" - " try {\n" - " event.messages.push({\n" - " role: 'user',\n" - " content: [{ type: 'text', text: note }],\n" - " });\n" - " } catch {\n" - " // Fail open: reinjection is best-effort.\n" - " }\n" - " });\n" - " sessionHooksRegistered = true;\n" - " } catch {\n" - " // Fail open: the server() compaction hook stays active.\n" - " }\n" - " }\n" " },\n" "};\n"); diff --git a/src/cli/client_adapter.h b/src/cli/client_adapter.h index 3c8fc39023..1eec930658 100644 --- a/src/cli/client_adapter.h +++ b/src/cli/client_adapter.h @@ -56,9 +56,10 @@ char *cbm_client_adapter_pi(const char *binary_path); * silently — no error, no signal. That risk is accepted deliberately for the * server() path and recorded here so it is not rediscovered as a mystery. * OpenCode 2 ignores `server` and calls `setup(ctx)`, which registers - * `execute.after` and `compaction` on the context domains. Each registration - * fails open, and the post-compaction note is added as a text message to the - * compaction request's `messages` (the summary input), never to `system`. + * `execute.after` on the context's tool domain and fails open. It registers no + * `compaction` hook: pushing a plain object into `event.messages` would fail, + * if at all, inside OpenCode's own compaction where the plugin cannot catch it, + * so a note after compaction needs a real 2.x run first. * * Returns NULL on allocation failure or when binary_path is NULL/empty. */ char *cbm_client_adapter_opencode(const char *binary_path); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 381bfda68e..c92005f9fb 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1344,11 +1344,10 @@ TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) { /* #2077/#2089/#2204: the two OpenCode runtimes read different entries of the * default export. 1.18.x dispatches the hooks returned by `server(ctx)`; 2.x - * ignores `server` and calls `setup(ctx)`, whose context provides the tool and - * session hook domains. Registrations fail open per domain, so the matching - * server() hook yields once its domain is registered. The post-compaction note - * goes into the compaction request's messages, never the system prompt (pinned - * by the compaction test below). */ + * ignores `server` and calls `setup(ctx)`, whose context provides the tool hook + * domain. The registration fails open, and the server() tool hook yields once + * it is registered. OpenCode 2 adds no note to a compaction (pinned by the + * compaction test below). */ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); @@ -1360,8 +1359,6 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { * 2.x, so setup is no longer the empty placeholder #2089 pinned. */ ASSERT_NOT_NULL(strstr(js, "async setup(ctx) {")); ASSERT_NOT_NULL(strstr(js, "ctx.tool.hook('execute.after'")); - ASSERT_NOT_NULL(strstr(js, "ctx.session.hook('compaction'")); - ASSERT_NULL(strstr(js, "ctx.session.hook('context'")); /* content is the surface the model reads; prefer it over output, and never * synthesize content that would hide the tool's own result. */ const char *content_first = strstr(js, "if (typeof result.content === 'string') {"); @@ -1382,11 +1379,9 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { * location in a multi-project server. */ ASSERT_NULL(strstr(js, "ctx?.location?.directory")); ASSERT_NULL(strstr(js, "pendingReinject")); - /* Registrations fail open per domain; the server() hooks yield per domain. */ + /* The registration fails open; the server() tool hook yields once it is in. */ ASSERT_NOT_NULL(strstr(js, "toolHooksRegistered = true;")); - ASSERT_NOT_NULL(strstr(js, "sessionHooksRegistered = true;")); ASSERT_NOT_NULL(strstr(js, "if (toolHooksRegistered) return;")); - ASSERT_NOT_NULL(strstr(js, "if (sessionHooksRegistered) return;")); ASSERT_NOT_NULL(strstr(js, "catch {")); ASSERT_NOT_NULL(strstr(js, "event.result = appendResult(event.result, extra);")); /* The empty setup placeholder and the old named export must both be gone. */ @@ -1396,38 +1391,23 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { PASS(); } -/* #2204: on OpenCode 2 the post-compaction note stays inside the compaction, - * at the level the 1.18.x path uses (output.context). The `compaction` hook - * receives the compaction request, whose `messages` are the input the summary - * is built from, so the note is pushed there as a text message. It must never - * reach the system prompt: not through `event.system`, and not through a - * session instruction entry, which OpenCode renders into the system array of - * every later request. */ -TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages) { +/* #2204: OpenCode 2 gets no note after compaction until a compaction channel has + * run on a real 2.x host. A plain object pushed into `event.messages` would be + * rejected, if at all, inside OpenCode's own compaction, where the plugin's + * try/catch cannot reach it, so an untested channel could break compaction for + * every OpenCode 2 user. The note must not reach the system prompt either: not + * through `event.system`, and not through a session instruction entry. The + * 1.18.x server() path keeps its documented compaction surface. */ +TEST(client_adapter_opencode_v2_adds_no_note_to_the_compaction) { char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); - const char *hook = strstr(js, "ctx.session.hook('compaction'"); - const char *push = strstr(js, "event.messages.push({"); - const char *registered = strstr(js, "sessionHooksRegistered = true;"); - ASSERT_NOT_NULL(hook); - ASSERT_NOT_NULL(push); - ASSERT_NOT_NULL(registered); - /* The push lives inside the compaction registration. */ - ASSERT_TRUE(hook < push); - ASSERT_TRUE(push < registered); - /* A text message that carries the note. */ - const char *role = strstr(push, "role: 'user',"); - const char *text = strstr(push, "content: [{ type: 'text', text: note }],"); - ASSERT_NOT_NULL(role); - ASSERT_NOT_NULL(text); - ASSERT_TRUE(role < registered); - ASSERT_TRUE(text < registered); - /* Fail open: a host whose event has no messages array skips reinjection. */ - ASSERT_NOT_NULL(strstr(js, "if (!Array.isArray(event?.messages)) return;")); - /* Never the system prompt, and no session instruction entry. */ + ASSERT_NULL(strstr(js, "ctx.session.hook(")); + ASSERT_NULL(strstr(js, "event.messages")); + ASSERT_NULL(strstr(js, "sessionHooksRegistered")); ASSERT_NULL(strstr(js, ".system")); ASSERT_NULL(strstr(js, "instructions")); ASSERT_NULL(strstr(js, "entry.put")); + ASSERT_NOT_NULL(strstr(js, "'experimental.session.compacting'")); free(js); PASS(); } @@ -1529,7 +1509,7 @@ SUITE(agent_clients) { RUN_TEST(client_adapter_opencode_sends_the_required_hook_event); RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction); RUN_TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077); - RUN_TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages); + RUN_TEST(client_adapter_opencode_v2_adds_no_note_to_the_compaction); RUN_TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content); RUN_TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child); RUN_TEST(client_adapter_rejects_missing_binary_path); From 54e079587d36a9833f844d972046d51da6b3bdf9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:09:38 +0000 Subject: [PATCH 95/97] build(deps): bump actions/attest from 4.1.0 to 4.2.2 Bumps [actions/attest](https://github.com/actions/attest) from 4.1.0 to 4.2.2. - [Release notes](https://github.com/actions/attest/releases) - [Changelog](https://github.com/actions/attest/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest/compare/59d89421af93a897026c735860bf21b6eb4f7b26...1e69f48acb82d1966a394da916b4c1698aa569d6) --- updated-dependencies: - dependency-name: actions/attest dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 707c699246..e4926260d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,7 +257,7 @@ jobs: # fail if its SBOM cannot be attested. An unattested SBOM is worse than # an absent one, because the artifact still ships looking complete. - name: Attest SBOM - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 env: NODE_OPTIONS: '--max-http-header-size=32768' with: From caad969154f19626e0d076091031530fc71a7532 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 16:12:47 +0100 Subject: [PATCH 96/97] ci: pin every CodeQL action to v4.39.1 Main already moved upload-sarif to v4.39.1 (#2779). Move init and analyze to the same commit, 9b3254085494b55a0f7c4c2b3d2d52cc712ce370 (the commit the v4.39.1 tag points to), so all three CodeQL actions run one version again. The pin comment follows the form Dependabot wrote for upload-sarif. The codeql-action Dependabot group added earlier on this branch keeps the three in lockstep from here on. Signed-off-by: Martin Vogel --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 18f9db5dbc..1a1c8d6fc4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -114,7 +114,7 @@ jobs: run: scripts/ci/codeql-retrace-vendored.sh "$RUNNER_TEMP/codeql-retrace.txt" - name: Initialize CodeQL - uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -129,7 +129,7 @@ jobs: run: scripts/build.sh --reuse-vendored --retrace-from "$RUNNER_TEMP/codeql-retrace.txt" - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: category: "/language:${{ matrix.language }}" From 1f56c7d6bb1f1ad10cece78d1da901709fa422a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:06:58 +0000 Subject: [PATCH 97/97] build(deps): bump msys2/setup-msys2 from 2.32.0 to 2.33.0 Bumps [msys2/setup-msys2](https://github.com/msys2/setup-msys2) from 2.32.0 to 2.33.0. - [Release notes](https://github.com/msys2/setup-msys2/releases) - [Changelog](https://github.com/msys2/setup-msys2/blob/main/CHANGELOG.md) - [Commits](https://github.com/msys2/setup-msys2/compare/66cd2cce69caa17b53920067426061ca1de3a884...ec48f7c5447b3140e2b088413ae3a55687bccb6e) --- updated-dependencies: - dependency-name: msys2/setup-msys2 dependency-version: 2.33.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/_build.yml | 4 ++-- .github/workflows/_memwaste.yml | 2 +- .github/workflows/_smoke.yml | 2 +- .github/workflows/_soak.yml | 6 +++--- .github/workflows/_test.yml | 4 ++-- .github/workflows/bug-repro.yml | 2 +- .github/workflows/cache-warm.yml | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/smoke.yml | 4 ++-- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 152cca7376..163376ab0c 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -120,7 +120,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit @@ -182,7 +182,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANGARM64 path-type: inherit diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index 72701ca7ad..52ae8a8fb3 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -149,7 +149,7 @@ jobs: timeout-minutes: 90 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index ed1022b718..ac4ca595d8 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -192,7 +192,7 @@ jobs: go-version-file: pkg/go/go.mod cache: false - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: ${{ matrix.arch == 'arm64' && 'CLANGARM64' || 'CLANG64' }} path-type: inherit diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index 39f9e76b1d..c25a1988dd 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -119,7 +119,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit @@ -197,7 +197,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANGARM64 path-type: inherit @@ -389,7 +389,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 501a82b205..da7e0aab03 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -594,7 +594,7 @@ jobs: # preflight script, so AV-interaction coverage lives there; runners run # without RTP by platform constraint. - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: ${{ matrix.msystem }} path-type: inherit @@ -746,7 +746,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/bug-repro.yml b/.github/workflows/bug-repro.yml index 3d10e42ab6..357aec5b2d 100644 --- a/.github/workflows/bug-repro.yml +++ b/.github/workflows/bug-repro.yml @@ -69,7 +69,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index 9951cd49ed..6615332389 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -82,7 +82,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e20ac274af..3b4ea807ca 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -219,7 +219,7 @@ jobs: if: matrix.os == 'macos-15' run: command -v ccache >/dev/null 2>&1 || brew install ccache - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 if: matrix.os == 'windows-latest' with: msystem: CLANG64 diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 66e62ff542..62193d0601 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -70,7 +70,7 @@ jobs: timeout-minutes: 240 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit @@ -100,7 +100,7 @@ jobs: continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANGARM64 path-type: inherit