From 45f0c430d28cf188753f44536d2cac26564a04ba Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 25 Sep 2026 19:10:07 +0200 Subject: [PATCH] fix(cross-repo): a pre-#768 store no longer aborts ["*"] or wipes edges (#2133) cross-repo-intelligence with target_projects ["*"] failed with "cross-repo source or target project is missing, invalid, or not indexed" while naming the same live targets worked. Root cause: the wildcard enumerator and the pre-write validation both used cr_project_exists(), which only does a read-only open plus the exact-project check. A store indexed before the #768 edges.local_name_gen column passes that (it still serves queries, so list_projects shows it), but the matcher's read-write open (store init_schema) refuses it until a reindex. One such legacy store anywhere in the cache therefore made every ["*"] run fail, and it failed only after delete_cross_edges() had already removed the source's previous CROSS_* generation, so a failed run was also destructive. Fix: the #768 probe moves into cbm_store_edges_schema_current() (init_schema now calls it too, one definition), and cr_project_exists() additionally requires it. The wildcard skips a legacy store (logged as cross_repo.project_unusable with the project name); naming one still fails, but during validation, before any write. The probe is read-only, so the legacy store is left untouched for its reindex. Refs #2133 Signed-off-by: Martin Vogel --- src/pipeline/pass_cross_repo.c | 12 +++++ src/store/store.c | 24 +++++---- src/store/store.h | 5 ++ tests/test_cross_repo.c | 89 ++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 9ce0e50fbc..faa7bff6f5 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -153,6 +153,13 @@ 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 + * 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) { char path[CR_PATH_BUF]; if (!cr_db_path(project, path, sizeof(path))) { @@ -160,6 +167,11 @@ static bool cr_project_exists(const char *project) { } 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)) { + cbm_log_warn("cross_repo.project_unusable", "project", project, "reason", + "pre_768_schema_reindex_required"); + exists = false; + } cbm_store_close(store); return exists; } diff --git a/src/store/store.c b/src/store/store.c index fa074cb5db..f88eb0884a 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -230,6 +230,18 @@ static void iso_now(char *buf, size_t sz) { /* ── Schema ─────────────────────────────────────────────────────── */ +bool cbm_store_edges_schema_current(cbm_store_t *s) { + sqlite3_stmt *probe = NULL; + if (!s || !s->db || + sqlite3_prepare_v2(s->db, "SELECT local_name_gen FROM edges LIMIT 0;", CBM_NOT_FOUND, + &probe, NULL) != SQLITE_OK) { + sqlite3_finalize(probe); + return false; + } + sqlite3_finalize(probe); + return true; +} + static int init_schema(cbm_store_t *s) { const char *ddl = "CREATE TABLE IF NOT EXISTS projects (" @@ -357,15 +369,9 @@ static int init_schema(cbm_store_t *s) { * callers already treat an unopenable DB as incompatible (a full index * deletes + rebuilds it, artifact import refuses and falls back to a * reindex). Read-only query opens skip init_schema and keep working. */ - { - sqlite3_stmt *probe = NULL; - if (sqlite3_prepare_v2(s->db, "SELECT local_name_gen FROM edges LIMIT 0;", CBM_NOT_FOUND, - &probe, NULL) != SQLITE_OK) { - cbm_log_warn("store.schema", "result", "incompatible", "missing", - "edges.local_name_gen"); - return CBM_STORE_ERR; - } - sqlite3_finalize(probe); + if (!cbm_store_edges_schema_current(s)) { + cbm_log_warn("store.schema", "result", "incompatible", "missing", "edges.local_name_gen"); + return CBM_STORE_ERR; } /* FTS5 contentless virtual table for BM25 full-text search. diff --git a/src/store/store.h b/src/store/store.h index b6ce844f75..06ea1fbb7e 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -371,6 +371,11 @@ const char *cbm_store_db_path(const cbm_store_t *s); * (projects table has correct types, no corruption indicators). * Returns false if corruption is detected — caller should delete and re-index. */ bool cbm_store_check_integrity(cbm_store_t *s); +/* True when the edges table carries the #768 local_name_gen discriminator — + * the schema a read-write open (cbm_store_open_path_existing) requires. A + * pre-#768 store still serves read-only queries but refuses every write open + * until it is reindexed. Read-only: safe on a store opened for query. */ +bool cbm_store_edges_schema_current(cbm_store_t *s); /* Shallow check + PRAGMA quick_check — catches page-level corruption. * O(db size); use on rare paths (artifact import), not hot opens. */ bool cbm_store_check_integrity_deep(cbm_store_t *s); diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 7b8af9bbd2..23e4bf3716 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -236,6 +236,93 @@ TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens) { PASS(); } +/* A project store written before the #768 edges.local_name_gen column: it + * still answers read-only queries (list_projects shows it), but the + * read-write open every cross-repo target needs refuses it until a reindex. */ +static bool cross_repo_create_pre768_project(const cross_repo_fixture_t *fixture, + const char *project) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return false; + } + sqlite3 *db = NULL; + if (sqlite3_open(path, &db) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + char sql[1024]; + 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: ["*"] enumerated every store a read-only open accepts, then aborted + * the whole run on the first one the matcher's read-write open refused — a + * single pre-#768 index anywhere in the cache made the wildcard fail with + * "missing, invalid, or not indexed" while naming the same live targets + * worked. A store the matcher cannot use is not a wildcard target. */ +TEST(cross_repo_wildcard_skips_pre768_store_issue2133) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "wild-src", "wild-api", "/orders", "w") && + cross_repo_create_pre768_project(&fixture, "aa-pre768-store"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed pre-#768 wildcard fixture"); + } + + const char *targets[] = {"*"}; + 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); + + ASSERT_FALSE(result.failed); + ASSERT_EQ(result.projects_scanned, 1); + ASSERT_EQ(result.http_edges, 1); + ASSERT_EQ(edges, 1); + PASS(); +} + +/* Naming an unusable store stays an error, but it must be refused during + * validation, before the source's previous CROSS_* generation is deleted. */ +TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "named-src", "named-api", "/orders", "n") && + cross_repo_create_pre768_project(&fixture, "aa-pre768-store"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed pre-#768 named fixture"); + } + + const char *live[] = {"named-api"}; + cbm_cross_repo_result_t initial = cbm_cross_repo_match("named-src", live, 1); + int before = cross_repo_count_edges(&fixture, "named-src", "CROSS_HTTP_CALLS"); + const char *with_pre768[] = {"named-api", "aa-pre768-store"}; + 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); + + ASSERT_FALSE(initial.failed); + ASSERT_EQ(before, 1); + ASSERT_TRUE(result.failed); + ASSERT_EQ(after, before); + 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 }; @@ -523,6 +610,8 @@ SUITE(cross_repo) { RUN_TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609); RUN_TEST(cross_repo_null_target_fails_without_dereference); RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens); + RUN_TEST(cross_repo_wildcard_skips_pre768_store_issue2133); + RUN_TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133); 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);