diff --git a/README.md b/README.md index da4d824e00..84193f1847 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,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 28d42230c3..25a23910bd 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -6940,6 +6940,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); @@ -10098,6 +10099,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 @@ -10135,9 +10137,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..09384a8e99 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] : ""); + } + 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}", + 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 375834a25a..846a34447d 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -2723,6 +2723,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 fd690b5daf..3cea85b90e 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -523,6 +523,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/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 cb4ab06071..23d74fb5b4 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 @@ -270,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; @@ -307,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); @@ -366,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); @@ -393,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); @@ -401,6 +408,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 }; @@ -488,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); @@ -517,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); @@ -540,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); @@ -592,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); @@ -623,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); @@ -656,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); @@ -679,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); @@ -729,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); @@ -756,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); @@ -789,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); @@ -817,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); @@ -836,6 +950,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 a8bea9024f..eb8bb998cb 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -13758,6 +13758,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. */ @@ -23454,6 +23550,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);