From e4f9735dea30dcc799ca5153a666ecdf9a00ca00 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 25 Sep 2026 20:08:36 +0200 Subject: [PATCH 1/3] feat(cross-repo): report run status and skipped legacy stores (#2133) Two follow-ups to the #2133 wildcard fix, both about what cross-repo intelligence tells the caller. 1. "Never run" vs "ran, no links". An empty CROSS_* set used to mean either one, and no output surface could tell them apart. Every run now records a marker in the SOURCE store's store_meta (key cross_repo_last_run:, same per-project key pattern as coverage_shadow_fp:) once the previous CROSS_* generation has been cleaned: last_run_at, outcome (complete|cancelled|failed), targets, projects_scanned, total_cross_edges, skipped_projects. index_status and the UI layout (next to linked_projects) now carry a `cross_repo` object: {"status":"never_run"} or {"status":"ran", ...}. The marker lives beside the edges it describes, so a reindex that rebuilds the store drops both together. No new database files and no new freshness key. New store API cbm_store_meta_get/put. put seeds a missing store_meta through the generation path, because creating the table without db_uid/mutation_gen would make cbm_store_generation() report malformed metadata. 2. ["*"] skips a pre-#768 store (since #2343) but only logged it. The result now names each one, and the MCP response lists them: skipped_projects: [{name, reason:"pre_768_schema", hint:"reindex this project"}] (only emitted when non-empty). The cross_repo.project_unusable log line is unchanged. cr_project_exists() is split into cr_project_state() (usable / missing / legacy) so that the enumerator can tell a legacy store apart from a foreign .db file. New allocations go through mem_core. Refs #2133 Signed-off-by: Martin Vogel --- README.md | 2 + src/mcp/mcp.c | 16 ++++ src/pipeline/pass_cross_repo.c | 169 ++++++++++++++++++++++++++++++--- src/pipeline/pass_cross_repo.h | 24 +++++ src/store/store.c | 71 ++++++++++++++ src/store/store.h | 11 +++ src/ui/http_server.c | 4 + tests/test_cross_repo.c | 93 ++++++++++++++++++ tests/test_mcp.c | 97 +++++++++++++++++++ 9 files changed, 476 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7564427eaf..2500d6c518 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,8 @@ The install script placed beside the binary is **reported, not deleted** — uni - **`CROSS_*` edges** link nodes across multiple repos indexed under the same store - **Multi-galaxy 3D UI layout** for cross-repo architecture visualization - **Cross-repo architecture summary** combining services, routes, and dependencies across the indexed fleet +- **Run status**: `index_status` (and the UI layout next to `linked_projects`) carries a `cross_repo` object, `{"status":"never_run"}` until `index_repository(mode="cross-repo-intelligence")` has run with that project as the source, then `"ran"` with `last_run_at`, `outcome`, `targets`, `projects_scanned`, `total_cross_edges` and `skipped_projects`. That is how you tell "never linked" apart from "linked, nothing matched". The status is stored in the project's own index next to its `CROSS_*` edges, so a reindex that rebuilds the index drops both at once and the status goes back to `never_run`. +- **Skipped stores**: `target_projects: ["*"]` skips an index built before the #768 schema change rather than failing the whole run. The response lists each one as `skipped_projects: [{"name", "reason": "pre_768_schema", "hint": "reindex this project"}]`. ### Edge types (selected) - `CALLS` — a callable is invoked at the source site diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 2ed29799b8..e67595882f 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -6906,6 +6906,7 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { } add_coverage_report(doc, root, store, project, have_proj_info ? proj_info.indexed_at : NULL, coverage_samples); + (void)cbm_cross_repo_add_status_json(doc, root, store, project); safe_str_free(&proj_info.name); safe_str_free(&proj_info.indexed_at); safe_str_free(&proj_info.root_path); @@ -10054,6 +10055,7 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path yyjson_doc_free(jdoc); if (result.no_targets || result.failed) { + cbm_cross_repo_result_free(&result); free(project); return cbm_mcp_text_result( result.no_targets @@ -10091,9 +10093,23 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path yyjson_mut_obj_add_int(doc, root, "cross_trpc_calls", result.trpc_edges); yyjson_mut_obj_add_int(doc, root, "total_cross_edges", total); yyjson_mut_obj_add_real(doc, root, "elapsed_ms", result.elapsed_ms); + /* ["*"] skips stores it cannot link (#2133); name them so a partial fleet + * is visible instead of silently smaller. Only emitted when non-empty. */ + if (result.skipped_count > 0) { + yyjson_mut_val *skipped = yyjson_mut_arr(doc); + for (int i = 0; i < result.skipped_count; i++) { + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, item, "name", result.skipped_projects[i].project); + yyjson_mut_obj_add_str(doc, item, "reason", result.skipped_projects[i].reason); + yyjson_mut_obj_add_str(doc, item, "hint", "reindex this project"); + yyjson_mut_arr_append(skipped, item); + } + yyjson_mut_obj_add_val(doc, root, "skipped_projects", skipped); + } char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); + cbm_cross_repo_result_free(&result); free(project); char *out = cbm_mcp_text_result(json, result.cancelled); free(json); diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index b86d8cee43..f645db3498 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -13,12 +13,14 @@ #include "pipeline/pipeline_internal.h" // cbm_route_canon_path #include "foundation/constants.h" #include "foundation/log.h" +#include "foundation/mem_core.h" #include "foundation/platform.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/str_util.h" #include +#include #include #include #include @@ -153,27 +155,38 @@ static bool cr_store_has_exact_project(cbm_store_t *store, const char *project) return matches; } -/* True when `project` is a usable cross-repo input: its store holds exactly - * that project AND carries the schema the read-write open in +typedef enum { + CR_PROJECT_USABLE = 0, + CR_PROJECT_MISSING, /* no store, or the store is not exactly this project */ + CR_PROJECT_LEGACY, /* exact project, but a pre-#768 schema (reindex required) */ +} cr_project_state_t; + +/* Classify `project` as a cross-repo input: usable when its store holds + * exactly that project AND carries the schema the read-write open in * cr_open_existing_project requires. Checking only the first let a pre-#768 * store (still readable, so list_projects shows it) pass validation and the * ["*"] enumeration, then abort the whole run at its write open — after the * source's previous CROSS_* generation had already been deleted. The probe is * read-only, so the legacy store is left untouched for its reindex. (#2133) */ -static bool cr_project_exists(const char *project) { +static cr_project_state_t cr_project_state(const char *project) { char path[CR_PATH_BUF]; if (!cr_db_path(project, path, sizeof(path))) { - return false; + return CR_PROJECT_MISSING; } cbm_store_t *store = cbm_store_open_path_query(path); - bool exists = cr_store_has_exact_project(store, project); - if (exists && !cbm_store_edges_schema_current(store)) { + cr_project_state_t state = + cr_store_has_exact_project(store, project) ? CR_PROJECT_USABLE : CR_PROJECT_MISSING; + if (state == CR_PROJECT_USABLE && !cbm_store_edges_schema_current(store)) { cbm_log_warn("cross_repo.project_unusable", "project", project, "reason", "pre_768_schema_reindex_required"); - exists = false; + state = CR_PROJECT_LEGACY; } cbm_store_close(store); - return exists; + return state; +} + +static bool cr_project_exists(const char *project) { + return cr_project_state(project) == CR_PROJECT_USABLE; } static cbm_store_t *cr_open_existing_project(const char *project) { @@ -1139,8 +1152,33 @@ static cr_match_result_t match_typed_routes(cbm_store_t *src_store, const char * static void free_project_list(char **projects, int count); +/* Remember a store the ["*"] enumeration skipped, so the caller can name it + * instead of the run silently covering fewer projects than exist. */ +static bool cr_record_skip(cbm_cross_repo_result_t *result, const char *project, + const char *reason) { + if (!result) { + return true; + } + cbm_cross_repo_skip_t *grown = + cbm_realloc(CBM_MEM_CLASS_OTHER, result->skipped_projects, + ((size_t)result->skipped_count + 1U) * sizeof(*result->skipped_projects)); + if (!grown) { + return false; + } + result->skipped_projects = grown; + char *name = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, project); + if (!name) { + return false; + } + grown[result->skipped_count].project = name; + grown[result->skipped_count].reason = reason; + result->skipped_count++; + return true; +} + /* When target_projects = ["*"], scan the cache directory for all .db files. */ -static int collect_all_projects(char ***out, cr_run_context_t *ctx) { +static int collect_all_projects(char ***out, cr_run_context_t *ctx, + cbm_cross_repo_result_t *result) { *out = NULL; const char *dir = cr_cache_dir(); cbm_dir_t *d = cbm_opendir(dir); @@ -1188,7 +1226,15 @@ static int collect_all_projects(char ***out, cr_run_context_t *ctx) { } memcpy(project, ent->name, project_length); project[project_length] = '\0'; - if (!cbm_validate_project_name(project) || !cr_project_exists(project)) { + if (!cbm_validate_project_name(project)) { + continue; + } + cr_project_state_t state = cr_project_state(project); + if (state == CR_PROJECT_LEGACY && !cr_record_skip(result, project, "pre_768_schema")) { + failed = true; + break; + } + if (state != CR_PROJECT_USABLE) { continue; } if (count >= cap) { @@ -1273,6 +1319,106 @@ static int collect_named_projects(const char **targets, int target_count, char * return unique_count; } +/* ── Last-run marker ─────────────────────────────────────────────── */ + +static bool cr_status_key(const char *project, char *buf, size_t bufsz) { + int written = snprintf(buf, bufsz, "cross_repo_last_run:%s", project); + return written > 0 && (size_t)written < bufsz; +} + +/* Record this run in the SOURCE store's store_meta, next to the CROSS_* + * edges it just rewrote: a reindex that rebuilds the store drops both + * together, so the marker can never outlive the links it describes. Written + * once the source's previous CROSS_* generation was cleaned, whatever the + * outcome, because from then on the old summary no longer matches the graph. + * Project names are validated ([A-Za-z0-9._-]) or "*", so no JSON escaping is + * needed. Best-effort: a marker failure is logged, never fails the run. */ +static void cr_record_last_run(cbm_store_t *src_store, const char *project, const char **targets, + int target_count, const cbm_cross_repo_result_t *result) { + char key[CR_QN_BUF]; + if (!cr_status_key(project, key, sizeof(key))) { + return; + } + size_t cap = CR_PROPS_BUF; + for (int i = 0; i < target_count; i++) { + cap += (targets[i] ? strlen(targets[i]) : 0) + CR_COL_4; + } + char *json = cbm_alloc(CBM_MEM_CLASS_OTHER, cap); + if (!json) { + return; + } + char when[CBM_SZ_32]; + time_t now = time(NULL); + struct tm tm_now; + if (!cbm_gmtime_r(&now, &tm_now) || + strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%SZ", &tm_now) == 0) { + when[0] = '\0'; + } + const char *outcome = result->failed ? "failed" : result->cancelled ? "cancelled" : "complete"; + size_t len = (size_t)snprintf( + json, cap, "{\"last_run_at\":\"%s\",\"outcome\":\"%s\",\"targets\":[", when, outcome); + for (int i = 0; i < target_count && len < cap; i++) { + len += (size_t)snprintf(json + len, cap - len, "%s\"%s\"", i ? "," : "", + targets[i] ? targets[i] : ""); + } + int total = result->http_edges + result->async_edges + result->channel_edges + + result->grpc_edges + result->graphql_edges + result->trpc_edges; + if (len < cap) { + len += (size_t)snprintf(json + len, cap - len, + "],\"projects_scanned\":%d,\"total_cross_edges\":%d," + "\"skipped_projects\":%d}", + result->projects_scanned, total, result->skipped_count); + } + if (len >= cap || cbm_store_meta_put(src_store, key, json) != CBM_STORE_OK) { + cbm_log_warn("cross_repo.status_unrecorded", "project", project); + } + cbm_free(CBM_MEM_CLASS_OTHER, json); +} + +bool cbm_cross_repo_add_status_json(struct yyjson_mut_doc *doc, struct yyjson_mut_val *parent, + cbm_store_t *store, const char *project) { + char key[CR_QN_BUF]; + if (!doc || !parent || !store || !project || !cr_status_key(project, key, sizeof(key))) { + return false; + } + char *raw = NULL; + int rc = cbm_store_meta_get(store, key, &raw); + yyjson_doc *recorded = rc == CBM_STORE_OK ? yyjson_read(raw, strlen(raw), 0) : NULL; + cbm_free(CBM_MEM_CLASS_STORE, raw); + yyjson_val *recorded_root = recorded ? yyjson_doc_get_root(recorded) : NULL; + yyjson_mut_val *status = NULL; + if (yyjson_is_obj(recorded_root)) { + status = yyjson_val_mut_copy(doc, recorded_root); + } + if (!status) { + status = yyjson_mut_obj(doc); + } + yyjson_doc_free(recorded); + if (!status) { + return false; + } + /* "never_run" is the honest reading of an absent marker: no run with this + * project as the source has completed its cleanup since the store was + * (re)built. A target-side run still leaves its CROSS_* edges here. */ + const char *state = rc == CBM_STORE_NOT_FOUND ? "never_run" + : yyjson_mut_obj_size(status) > 0 ? "ran" + : "unavailable"; + yyjson_mut_obj_add_str(doc, status, "status", state); + return yyjson_mut_obj_add_val(doc, parent, "cross_repo", status); +} + +void cbm_cross_repo_result_free(cbm_cross_repo_result_t *result) { + if (!result) { + return; + } + for (int i = 0; i < result->skipped_count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, result->skipped_projects[i].project); + } + cbm_free(CBM_MEM_CLASS_OTHER, result->skipped_projects); + result->skipped_projects = NULL; + result->skipped_count = 0; +} + static cr_run_status_t add_match_count(int *total, cr_match_result_t matched) { *total += matched.count; return matched.status; @@ -1304,7 +1450,7 @@ cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project, int resolved_count = 0; if (target_count == SKIP_ONE && target_projects[0] && strcmp(target_projects[0], "*") == 0) { - resolved_count = collect_all_projects(&resolved, &run); + resolved_count = collect_all_projects(&resolved, &run, &result); } else { resolved_count = collect_named_projects(target_projects, target_count, &resolved, &run); } @@ -1442,6 +1588,7 @@ cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project, result.projects_scanned++; } + cr_record_last_run(src_store, project, target_projects, target_count, &result); cbm_store_close(src_store); free_project_list(resolved, resolved_count); diff --git a/src/pipeline/pass_cross_repo.h b/src/pipeline/pass_cross_repo.h index 898283c3e3..85c832b2d9 100644 --- a/src/pipeline/pass_cross_repo.h +++ b/src/pipeline/pass_cross_repo.h @@ -9,6 +9,12 @@ #include +/* A store the ["*"] enumeration skipped instead of linking. */ +typedef struct { + char *project; /* heap-owned */ + const char *reason; /* static, e.g. "pre_768_schema" (reindex required) */ +} cbm_cross_repo_skip_t; + /* Result of a cross-repo matching run. */ typedef struct { int http_edges; /* CROSS_HTTP_CALLS edges created */ @@ -23,8 +29,26 @@ typedef struct { bool no_targets; /* failed: nothing but the source project resolved (#1133) */ bool cancelled; /* stopped at a bounded cancellation checkpoint */ bool partial_results; /* committed writes before cancellation were retained */ + cbm_cross_repo_skip_t *skipped_projects; /* ["*"] only; free with ..._result_free */ + int skipped_count; } cbm_cross_repo_result_t; +/* Release the heap-owned parts of a result (safe on a zeroed result). */ +void cbm_cross_repo_result_free(cbm_cross_repo_result_t *result); + +struct yyjson_mut_doc; +struct yyjson_mut_val; + +/* Add a "cross_repo" object to `parent` describing the last cross-repo run + * with `project` as the source, as recorded in that project's store: + * {"status":"never_run"} -- no run recorded since the store was (re)built + * {"status":"ran","last_run_at","outcome","targets","projects_scanned", + * "total_cross_edges","skipped_projects"} + * Read-only (works on a query store). Returns false only on invalid input or + * allocation failure. */ +bool cbm_cross_repo_add_status_json(struct yyjson_mut_doc *doc, struct yyjson_mut_val *parent, + cbm_store_t *store, const char *project); + /* Run cross-repo matching for `project` against `target_projects`. * If target_count == 1 and target_projects[0] == "*", matches against all * indexed projects. Writes CROSS_* edges bidirectionally into both the diff --git a/src/store/store.c b/src/store/store.c index d66ef0ffbb..430efd8a3a 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -2456,6 +2456,77 @@ int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) { return CBM_STORE_OK; } +int cbm_store_meta_get(cbm_store_t *s, const char *key, char **out) { + if (!out) { + return CBM_STORE_ERR; + } + *out = NULL; + if (!s || !s->db || !key) { + return CBM_STORE_ERR; + } + sqlite3_stmt *get = NULL; + /* A store that never advanced its generation has no store_meta table: + * that is "no value recorded", not an error. */ + if (sqlite3_prepare_v2(s->db, "SELECT v FROM store_meta WHERE k = ?1;", CBM_NOT_FOUND, &get, + NULL) != SQLITE_OK) { + sqlite3_finalize(get); + return CBM_STORE_NOT_FOUND; + } + bind_text(get, SKIP_ONE, key); + int rc = sqlite3_step(get); + if (rc == SQLITE_ROW) { + const char *value = (const char *)sqlite3_column_text(get, 0); + *out = cbm_mem_strdup(CBM_MEM_CLASS_STORE, value ? value : ""); + sqlite3_finalize(get); + return *out ? CBM_STORE_OK : CBM_STORE_ERR; + } + sqlite3_finalize(get); + return rc == SQLITE_DONE ? CBM_STORE_NOT_FOUND : CBM_STORE_ERR; +} + +int cbm_store_meta_put(cbm_store_t *s, const char *key, const char *value) { + static const char savepoint[] = "SAVEPOINT cbm_meta_put;"; + static const char rollback[] = "ROLLBACK TO cbm_meta_put;"; + static const char release[] = "RELEASE cbm_meta_put;"; + if (!s || !s->db || !key || !value) { + return CBM_STORE_ERR; + } + if (exec_sql(s, savepoint) != CBM_STORE_OK) { + return CBM_STORE_ERR; + } + /* store_meta is only ever created by the generation seed, which writes + * db_uid + mutation_gen with it. Creating the table here without them + * would make cbm_store_generation() report malformed metadata, so an + * absent table is seeded through that one path. */ + bool absent = false; + char uid[17] = {0}; + char counter[21] = {0}; + uint64_t counter_value = 0; + if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK || + (absent && cbm_store_generation_advance(s) != CBM_STORE_OK)) { + return rollback_savepoint_preserving_error(s, rollback, release); + } + sqlite3_stmt *set = NULL; + if (sqlite3_prepare_v2(s->db, "INSERT OR REPLACE INTO store_meta (k, v) VALUES (?1, ?2);", + CBM_NOT_FOUND, &set, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "store_meta put prepare"); + sqlite3_finalize(set); + return rollback_savepoint_preserving_error(s, rollback, release); + } + bind_text(set, SKIP_ONE, key); + bind_text(set, ST_COL_2, value); + int rc = sqlite3_step(set); + sqlite3_finalize(set); + if (rc != SQLITE_DONE) { + store_set_error_sqlite(s, "store_meta put"); + return rollback_savepoint_preserving_error(s, rollback, release); + } + if (exec_sql(s, release) != CBM_STORE_OK) { + return rollback_savepoint_preserving_error(s, rollback, release); + } + return CBM_STORE_OK; +} + int cbm_store_get_project(cbm_store_t *s, const char *name, cbm_project_t *out) { sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_get_project, diff --git a/src/store/store.h b/src/store/store.h index 06ea1fbb7e..ca4f1eb58a 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -462,6 +462,17 @@ int cbm_store_generation_advance(cbm_store_t *s); * an error. */ int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz); +/* Per-store feature metadata in store_meta (key/value text). Keys are + * namespaced by the owning feature, e.g. "cross_repo_last_run:". + * get: CBM_STORE_OK with *out owned by the caller (release with + * cbm_free(CBM_MEM_CLASS_STORE, *out)), or + * CBM_STORE_NOT_FOUND when the key -- or store_meta itself -- is absent. + * Read-only: safe on a store opened for query. + * put: upserts the key; seeds store_meta through the generation path when a + * legacy store lacks it. Atomic (savepoint); needs a read-write store. */ +int cbm_store_meta_get(cbm_store_t *s, const char *key, char **out); +int cbm_store_meta_put(cbm_store_t *s, const char *key, const char *value); + /* Seal a fully-written staging database before atomic publication. * Raises synchronous to FULL, requires an exclusive TRUNCATE checkpoint to * complete, then leaves the database in verified DELETE journal mode so the diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 66f1affb88..ae0dd27542 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -19,6 +19,7 @@ #include "ui/layout3d.h" #include "mcp/mcp.h" #include "store/store.h" +#include "pipeline/pass_cross_repo.h" #include "watcher/watcher.h" #include "cli/cli.h" #include "git/git_context.h" @@ -1729,6 +1730,9 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { free(linked[li]); } + /* Next to linked_projects: an empty list alone cannot tell "never linked" + * from "linked, nothing matched". */ + (void)cbm_cross_repo_add_status_json(mdoc, mroot, store, project); cbm_store_close(store); yyjson_mut_obj_add_val(mdoc, mroot, "linked_projects", lp_arr); diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index cb4ab06071..54c75cbd56 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -10,6 +10,7 @@ #include "pipeline/pipeline_internal.h" #include +#include #include #include #include @@ -401,6 +402,96 @@ TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133) { PASS(); } +/* Render the "cross_repo" status object the MCP/UI surfaces attach for + * `project`, read from its store the way index_status does (query open). */ +static char *cross_repo_status_json(const cross_repo_fixture_t *fixture, const char *project) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return NULL; + } + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) { + return NULL; + } + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + char *json = NULL; + if (cbm_cross_repo_add_status_json(doc, root, store, project)) { + json = yyjson_mut_write(doc, 0, NULL); + } + yyjson_mut_doc_free(doc); + cbm_store_close(store); + return json; +} + +/* #2133 follow-up: an empty CROSS_* set could mean "never linked" or "linked, + * nothing matched" and no surface told them apart. The source store records + * each run; before any run the status says so explicitly. */ +TEST(cross_repo_status_distinguishes_never_run_from_ran_without_links) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_create_project(&fixture, "status-src") && + cross_repo_create_project(&fixture, "status-api"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed status fixture"); + } + + char *before = cross_repo_status_json(&fixture, "status-src"); + const char *targets[] = {"status-api"}; + cbm_cross_repo_result_t result = cbm_cross_repo_match("status-src", targets, 1); + char *after = cross_repo_status_json(&fixture, "status-src"); + cross_repo_fixture_end(&fixture); + + bool before_never = before && strstr(before, "\"cross_repo\":{\"status\":\"never_run\"}"); + bool after_ran = + after && strstr(after, "\"status\":\"ran\"") && strstr(after, "\"outcome\":\"complete\"") && + strstr(after, "\"targets\":[\"status-api\"]") && strstr(after, "\"projects_scanned\":1") && + strstr(after, "\"total_cross_edges\":0") && strstr(after, "\"last_run_at\":\"20"); + free(before); + free(after); + cbm_cross_repo_result_free(&result); + + ASSERT_FALSE(result.failed); + ASSERT_TRUE(before_never); + ASSERT_TRUE(after_ran); + PASS(); +} + +/* #2133 follow-up: ["*"] skips a pre-#768 store (it cannot be linked until a + * reindex); the result must name it rather than silently covering fewer + * projects, and the recorded status counts it. */ +TEST(cross_repo_wildcard_reports_skipped_pre768_store) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "skip-src", "skip-api", "/orders", "s") && + cross_repo_create_pre768_project(&fixture, "aa-pre768-store"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed skipped-store fixture"); + } + + const char *targets[] = {"*"}; + cbm_cross_repo_result_t result = cbm_cross_repo_match("skip-src", targets, 1); + char *status = cross_repo_status_json(&fixture, "skip-src"); + cross_repo_fixture_end(&fixture); + + bool named = result.skipped_count == 1 && result.skipped_projects && + strcmp(result.skipped_projects[0].project, "aa-pre768-store") == 0 && + strcmp(result.skipped_projects[0].reason, "pre_768_schema") == 0; + bool status_counts = + status && strstr(status, "\"skipped_projects\":1") && strstr(status, "\"targets\":[\"*\"]"); + free(status); + cbm_cross_repo_result_free(&result); + + ASSERT_FALSE(result.failed); + ASSERT_EQ(result.projects_scanned, 1); + ASSERT_TRUE(named); + ASSERT_TRUE(status_counts); + PASS(); +} + static bool cross_repo_seed_bounded_scan(const cross_repo_fixture_t *fixture, const char *source_project, const char *target_project) { enum { TEST_SCAN_ROWS = 4097 }; @@ -836,6 +927,8 @@ SUITE(cross_repo) { RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens); RUN_TEST(cross_repo_wildcard_skips_pre768_store_issue2133); RUN_TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133); + RUN_TEST(cross_repo_status_distinguishes_never_run_from_ran_without_links); + RUN_TEST(cross_repo_wildcard_reports_skipped_pre768_store); RUN_TEST(cross_repo_scan_bound_counts_examined_rows_not_matches); RUN_TEST(cross_repo_propagates_delete_failure); RUN_TEST(cross_repo_failed_bidirectional_insert_is_not_counted); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 7a2219c5a6..883812157b 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -13033,6 +13033,102 @@ TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting) { PASS(); } +/* A store written before the #768 edges.local_name_gen column: readable, but + * refused by every read-write open until a reindex. */ +static bool mcp_cross_repo_create_pre768_store(const char *cache, const char *project) { + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + sqlite3 *db = NULL; + if (sqlite3_open(db_path, &db) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + char sql[CBM_SZ_1K]; + snprintf(sql, sizeof(sql), + "CREATE TABLE projects(name TEXT PRIMARY KEY, indexed_at TEXT NOT NULL," + " root_path TEXT NOT NULL);" + "CREATE TABLE nodes(id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL," + " label TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL," + " file_path TEXT DEFAULT '', start_line INTEGER DEFAULT 0," + " end_line INTEGER DEFAULT 0, properties TEXT DEFAULT '{}'," + " UNIQUE(project, qualified_name));" + "CREATE TABLE edges(id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL," + " source_id INTEGER NOT NULL, target_id INTEGER NOT NULL, type TEXT NOT NULL," + " properties TEXT DEFAULT '{}', UNIQUE(source_id, target_id, type));" + "INSERT INTO projects VALUES('%s', '2026-06-01T00:00:00Z', '/pre768');", + project); + bool ok = sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK; + sqlite3_close(db); + return ok; +} + +/* #2133 follow-up, both user-visible halves: index_status tells "never run" + * from "ran" via a `cross_repo` object, and a ["*"] run names the legacy + * store it skipped (with the reindex hint) instead of silently linking fewer + * projects. */ +TEST(tool_cross_repo_status_and_wildcard_skipped_projects) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-status-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + 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); + + char *source_project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(source_project); + const char *target_project = "cross-status-target"; + const char *legacy_project = "cross-status-pre768"; + ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache)); + ASSERT_TRUE(mcp_cross_repo_create_pre768_store(cache, legacy_project)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + + char status_args[CBM_SZ_1K]; + snprintf(status_args, sizeof(status_args), "{\"project\":\"%s\",\"format\":\"json\"}", + source_project); + char *before = cbm_mcp_handle_tool(srv, "index_status", status_args); + bool before_never = + response_contains_json_fragment(before, "\"cross_repo\":{\"status\":\"never_run\"}"); + + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"*\"]}", + cache); + char *run = cbm_mcp_handle_tool(srv, "index_repository", args); + bool run_ok = run && strstr(run, "\"isError\":true") == NULL && + response_contains_json_fragment(run, "\"total_cross_edges\":1"); + bool skipped_named = response_contains_json_fragment( + run, "\"skipped_projects\":[{\"name\":\"cross-status-pre768\"," + "\"reason\":\"pre_768_schema\",\"hint\":\"reindex this project\"}]"); + + char *after = cbm_mcp_handle_tool(srv, "index_status", status_args); + bool after_ran = response_contains_json_fragment(after, "\"status\":\"ran\"") && + response_contains_json_fragment(after, "\"total_cross_edges\":1") && + response_contains_json_fragment(after, "\"skipped_projects\":1"); + + free(before); + free(run); + free(after); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, source_project); + cleanup_project_db(cache, target_project); + cleanup_project_db(cache, legacy_project); + free(source_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(before_never); + ASSERT_TRUE(run_ok); + ASSERT_TRUE(skipped_named); + ASSERT_TRUE(after_ran); + PASS(); +} + /* `name` is the documented index project-name override and must identify the * cross-repo source too. Deriving from repo_path here makes custom-named * projects impossible to rescan even though ordinary indexing created them. */ @@ -21212,6 +21308,7 @@ SUITE(mcp_mutation_guard) { RUN_TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases); RUN_TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases); RUN_TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting); + RUN_TEST(tool_cross_repo_status_and_wildcard_skipped_projects); RUN_TEST(tool_cross_repo_honors_source_name_override); RUN_TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested); RUN_TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal); From bc66208be6a240a684ff1b4bcf6dab60dd048851 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 1 Oct 2026 19:11:51 +0200 Subject: [PATCH 2/3] style(cross-repo): narrow the scope of the status edge total CI's cppcheck flags `total` in the cross-repo status writer (variableScope): it is only read inside the `len < cap` branch. Move the declaration into that branch. No behaviour change. Signed-off-by: Martin Vogel --- src/pipeline/pass_cross_repo.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index f645db3498..09384a8e99 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -1361,9 +1361,9 @@ static void cr_record_last_run(cbm_store_t *src_store, const char *project, cons len += (size_t)snprintf(json + len, cap - len, "%s\"%s\"", i ? "," : "", targets[i] ? targets[i] : ""); } - int total = result->http_edges + result->async_edges + result->channel_edges + - result->grpc_edges + result->graphql_edges + result->trpc_edges; if (len < cap) { + int total = result->http_edges + result->async_edges + result->channel_edges + + result->grpc_edges + result->graphql_edges + result->trpc_edges; len += (size_t)snprintf(json + len, cap - len, "],\"projects_scanned\":%d,\"total_cross_edges\":%d," "\"skipped_projects\":%d}", From f62d36d083e5a1e814431a573bb65fd9fd9bd37b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 05:03:35 +0200 Subject: [PATCH 3/3] test(cross-repo): free every matcher result the tests receive Since #2133 a cross-repo result owns heap memory: a wildcard run records each store it skips in result.skipped_projects, and the caller releases it with cbm_cross_repo_result_free(). Fifteen tests in test_cross_repo.c received a result and never released it. The one that seeds a pre-#768 store next to a wildcard target records a skip, so it leaked the skip array and the skipped project name: 32 bytes in 2 allocations, allocated in cr_record_skip. Every assertion still passed. The macOS leak lane (test-lsan-macos) was the run that reported it, because it builds with Homebrew LLVM and sets detect_leaks=1. The ordinary macOS runs, the local ones included, are built with Apple clang, where leak detection is off and cannot be turned on, so they stayed green. Each test now releases its result right after the fixture is torn down and before the first assertion, which is the shape the two tests that already released theirs use. The release therefore also runs when an assertion fails, and the scalar fields the assertions read stay valid after it. The repro_issue523 cases release their results the same way. They name their targets, so they record no skip and did not leak, but they hold a result and follow the same ownership rule. Signed-off-by: Martin Vogel --- tests/repro/repro_issue523.c | 7 +++++++ tests/test_cross_repo.c | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/tests/repro/repro_issue523.c b/tests/repro/repro_issue523.c index 82d45b38d2..14191e0047 100644 --- a/tests/repro/repro_issue523.c +++ b/tests/repro/repro_issue523.c @@ -195,6 +195,7 @@ TEST(repro_issue523_crossrepo_http_calls_edge) { /* ── Cleanup ──────────────────────────────────────────────── */ rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&result); /* * WHY RED: result.http_edges == 0 on current code. @@ -315,6 +316,7 @@ TEST(repro_issue523_scheme_stripped_url_match) { rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&r); ASSERT_GTE(r.http_edges, 1); PASS(); } @@ -355,6 +357,7 @@ TEST(repro_issue523_template_fuzzy_match) { rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&r); ASSERT_GTE(r.http_edges, 1); PASS(); } @@ -398,6 +401,7 @@ TEST(repro_issue523_reverse_direction_match) { rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&r); ASSERT_GTE(r.http_edges, 1); ASSERT_GTE(client_cross, 1); PASS(); @@ -464,6 +468,9 @@ TEST(repro_issue523_idempotent_cross_edges) { rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&r1); + cbm_cross_repo_result_free(&r2); + cbm_cross_repo_result_free(&r3); ASSERT_EQ(client_1, 1); ASSERT_EQ(server_1, 1); diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 54c75cbd56..23d74fb5b4 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -271,12 +271,14 @@ TEST(cross_repo_null_target_fails_without_dereference) { const char *targets[] = {NULL}; cbm_cross_repo_result_t result = cbm_cross_repo_match("null-target-source", targets, 1); rejected = result.failed; + cbm_cross_repo_result_free(&result); #else fflush(NULL); pid_t child = fork(); if (child == 0) { const char *targets[] = {NULL}; cbm_cross_repo_result_t result = cbm_cross_repo_match("null-target-source", targets, 1); + cbm_cross_repo_result_free(&result); _exit(result.failed ? 0 : 2); } int status = 0; @@ -308,6 +310,7 @@ TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens) { const char *targets[] = {"*"}; cbm_cross_repo_result_t result = cbm_cross_repo_match("wildcard-source", targets, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(result.failed); ASSERT_EQ(result.projects_scanned, 4); @@ -367,6 +370,7 @@ TEST(cross_repo_wildcard_skips_pre768_store_issue2133) { cbm_cross_repo_result_t result = cbm_cross_repo_match("wild-src", targets, 1); int edges = cross_repo_count_edges(&fixture, "wild-src", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(result.failed); ASSERT_EQ(result.projects_scanned, 1); @@ -394,6 +398,8 @@ TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133) { cbm_cross_repo_result_t result = cbm_cross_repo_match("named-src", with_pre768, 2); int after = cross_repo_count_edges(&fixture, "named-src", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&initial); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(initial.failed); ASSERT_EQ(before, 1); @@ -579,6 +585,7 @@ TEST(cross_repo_scan_bound_counts_examined_rows_not_matches) { const char *target = "bounded-target"; cbm_cross_repo_result_t result = cbm_cross_repo_match("bounded-source", &target, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(result.failed); ASSERT_EQ(result.projects_scanned, 1); @@ -608,6 +615,8 @@ TEST(cross_repo_propagates_delete_failure) { failed = cbm_cross_repo_match("delete-source", &target, 1); } cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&initial); + cbm_cross_repo_result_free(&failed); ASSERT_TRUE(trigger_created); ASSERT_TRUE(failed.failed); @@ -631,6 +640,7 @@ TEST(cross_repo_failed_bidirectional_insert_is_not_counted) { const char *target = "insert-target"; cbm_cross_repo_result_t result = cbm_cross_repo_match("insert-source", &target, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_TRUE(result.failed); ASSERT_EQ(result.http_edges, 0); @@ -683,6 +693,7 @@ TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_tar int later_target_edges = cross_repo_count_edges(&fixture, "cancel-target-c", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_EQ(hook.fired, 1); ASSERT_TRUE(result.cancelled); @@ -714,6 +725,8 @@ TEST(cross_repo_pre_cancel_preserves_existing_cross_edges) { cbm_cross_repo_match_cancellable("pre-cancel-source", &target, 1, &cancelled); int after = cross_repo_count_edges(&fixture, "pre-cancel-source", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&initial); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(initial.failed); ASSERT_TRUE(before > 0); @@ -747,6 +760,8 @@ TEST(cross_repo_self_only_target_fails_and_keeps_edges_issue1133) { cbm_cross_repo_result_t result = cbm_cross_repo_match("self-source", &self, 1); int after = cross_repo_count_edges(&fixture, "self-source", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&initial); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(initial.failed); ASSERT_TRUE(before > 0); @@ -770,6 +785,7 @@ TEST(cross_repo_wildcard_with_no_other_project_fails_issue1133) { const char *targets[] = {"*"}; cbm_cross_repo_result_t result = cbm_cross_repo_match("lonely-source", targets, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_TRUE(result.failed); ASSERT_TRUE(result.no_targets); @@ -820,6 +836,7 @@ TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609) { const char *target = "shadow-target"; cbm_cross_repo_result_t result = cbm_cross_repo_match("shadow-source", &target, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(result.failed); ASSERT_EQ(result.projects_scanned, 1); @@ -847,6 +864,8 @@ TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459) { int mono_edges = cross_repo_count_edges(&fixture, "mono-1459", "CROSS_HTTP_CALLS"); int api_edges = cross_repo_count_edges(&fixture, "api-1459", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); ASSERT_FALSE(fwd.failed); ASSERT_FALSE(rev.failed); @@ -880,6 +899,8 @@ TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459) int mono_edges = cross_repo_count_edges(&fixture, "mono2-1459", "CROSS_HTTP_CALLS"); int api_edges = cross_repo_count_edges(&fixture, "api3-1459", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); ASSERT_FALSE(fwd.failed); ASSERT_FALSE(rev.failed); @@ -908,6 +929,8 @@ TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) { const char *consumer = "client-1459"; cbm_cross_repo_result_t rev = cbm_cross_repo_match("api2-1459", &consumer, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); ASSERT_FALSE(fwd.failed); ASSERT_FALSE(rev.failed);