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:<project>, same per-project key pattern as
   coverage_shadow_fp:<project>) 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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-09-30 22:40:38 +02:00
1 parent 0f52d30c29
commit e4f9735dea
9 files changed
+476 -11

No files matched your search

+2
View File
@@ -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
+16
View File
@@ -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);
+158 -11
View File
@@ -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 <sqlite3/sqlite3.h>
#include <yyjson/yyjson.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@@ -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);
+24
View File
@@ -9,6 +9,12 @@
#include <stdatomic.h>
/* 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
+71
View File
@@ -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,
+11
View File
@@ -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:<project>".
* 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
+4
View File
@@ -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);
+93
View File
@@ -10,6 +10,7 @@
#include "pipeline/pipeline_internal.h"
#include <sqlite3/sqlite3.h>
#include <yyjson/yyjson.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
@@ -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);
+97
View File
@@ -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);