From 36d467dc2c37abc95f5bad6ede4160d5cb6f9655 Mon Sep 17 00:00:00 2001 From: Leonardo trindade miranda Date: Tue, 11 Aug 2026 23:22:21 -0300 Subject: [PATCH 1/5] fix(mcp): fail closed when indexed-checkout identity is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live checkout SHA is not proof of the generation that produced graph content, so the freshness verdict must come from the indexed-checkout identity recorded with the DB. Legacy DBs record no such identity: verbose index_status now exposes the graph generation separately (indexed_generation) and fails closed with verdict=unknown, indexed_checkout_sha=null and reason=indexed_checkout_unavailable. The default status call stays lean and the freshness block is report-only — it never triggers indexing. Add focused coverage for the fail-closed verdict and for its omission from the default output. Signed-off-by: Leonardo trindade miranda --- src/mcp/mcp.c | 27 ++++++++++++++++++++++--- tests/test_mcp.c | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 2bbbec52d..7b1654b25 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -4456,13 +4456,33 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args return result; } +/* BT-240: fail-closed freshness verdict. A live checkout SHA is not proof of + * the generation that produced graph content, so the verdict must come from + * the indexed-checkout identity recorded with the DB. Legacy DBs record no + * such identity: expose the graph generation separately and fail closed as + * unknown rather than pretending the live checkout produced the graph. This + * is a report-only signal — it never triggers indexing. */ +static void add_index_freshness_json(yyjson_mut_doc *doc, yyjson_mut_val *root, + const char *indexed_generation) { + yyjson_mut_val *freshness = yyjson_mut_obj(doc); + if (indexed_generation && indexed_generation[0]) { + yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_generation", indexed_generation); + } else { + yyjson_mut_obj_add_str(doc, freshness, "indexed_generation", ""); + } + yyjson_mut_obj_add_null(doc, freshness, "indexed_checkout_sha"); + yyjson_mut_obj_add_str(doc, freshness, "verdict", "unknown"); + yyjson_mut_obj_add_str(doc, freshness, "reason", "indexed_checkout_unavailable"); + yyjson_mut_obj_add_val(doc, root, "freshness", freshness); +} + static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); cbm_store_t *store = resolve_store(srv, project); REQUIRE_STORE(store, project); - /* The git context block (worktree/shadow path variants) only matters when - * debugging index-location issues — gate it so the common status call - * stays lean. */ + /* The git context block (worktree/shadow path variants) and the freshness + * verdict only matter when debugging index-location issues — gate them so + * the common status call stays lean. */ bool verbose = cbm_mcp_get_bool_arg(args, "verbose"); yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); @@ -4482,6 +4502,7 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { proj_info.root_path ? proj_info.root_path : ""); if (verbose) { add_git_context_json(doc, root, proj_info.root_path); + add_index_freshness_json(doc, root, proj_info.indexed_at); } safe_str_free(&proj_info.name); safe_str_free(&proj_info.indexed_at); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 854330053..e9d099b12 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2642,6 +2642,56 @@ TEST(tool_index_status_includes_git_metadata) { PASS(); } +/* BT-240 RED: a live checkout SHA is not proof of the generation that produced + * graph content. Older databases lack an indexed-checkout identity, so verbose + * status must fail closed as unknown while exposing graph generation + * separately. */ +TEST(tool_index_status_fails_closed_without_indexed_checkout_identity) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"test-project\",\"verbose\":true}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"freshness\"")); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\"")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_generation\"")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":null")); + ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_unavailable")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* BT-240 lean-default guard: the freshness verdict is diagnostics — a + * report-only signal that never auto-indexes — so it must stay out of the + * common (non-verbose) status call. */ +TEST(tool_index_status_omits_freshness_by_default) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool(srv, "index_status", "{\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"status\"")); + ASSERT_NULL(strstr(inner, "\"freshness\"")); + ASSERT_NULL(strstr(inner, "indexed_checkout_sha")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * TOOL HANDLERS WITH DATA * ══════════════════════════════════════════════════════════════════ */ @@ -10541,6 +10591,8 @@ SUITE(mcp) { RUN_TEST(tool_check_index_coverage_requires_source_when_file_metadata_changed); RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors); RUN_TEST(tool_index_status_includes_git_metadata); + RUN_TEST(tool_index_status_fails_closed_without_indexed_checkout_identity); + RUN_TEST(tool_index_status_omits_freshness_by_default); /* Tool handlers with validation */ RUN_TEST(tool_trace_call_path_not_found); From e49f7e5729fb64b7ea4be53f3280fc7638236fe8 Mon Sep 17 00:00:00 2001 From: Leonardo trindade miranda Date: Tue, 11 Aug 2026 23:33:30 -0300 Subject: [PATCH 2/5] feat(store): persist indexed-checkout sha at staged-generation boundary Record the frozen git HEAD at the same successful staged-generation boundary as graph coverage. cbm_coverage_meta_t gains indexed_checkout_sha; new writable DBs create a nullable indexed_checkout_sha column and existing writable DBs are migrated idempotently in init_schema, while read-only legacy DBs without the column still open, read and report no identity instead of erroring. coverage_replace_ex writes/updates the SHA inside its existing transaction, so failure/rollback retains the prior identity. Full, delta-incremental and legacy-incremental generation templates set it from the pipeline's refreshed git context (NULL/empty for non-git repos). index_status freshness now derives from the recorded identity: no indexed SHA -> unknown/indexed_checkout_unavailable; differs from the live git HEAD -> stale/indexed_checkout_mismatch; equal -> current. Emits indexed_generation, indexed_checkout_sha, checkout_sha, a stable reasons array and recommended_action; stays verbose-only and read-only. Add store round-trip/rollback coverage and MCP verdict tests for matching current and mismatched stale (git fixtures, platform-skipped on Windows). Signed-off-by: Leonardo trindade miranda --- src/mcp/mcp.c | 74 ++++++++++++--- src/pipeline/pipeline.c | 1 + src/pipeline/pipeline_incremental.c | 2 + src/store/store.c | 77 +++++++++++++-- src/store/store.h | 5 + tests/test_mcp.c | 141 +++++++++++++++++++++++++++- tests/test_store_nodes.c | 68 ++++++++++++++ 7 files changed, 345 insertions(+), 23 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7b1654b25..4a596504e 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -4456,24 +4456,75 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args return result; } -/* BT-240: fail-closed freshness verdict. A live checkout SHA is not proof of - * the generation that produced graph content, so the verdict must come from - * the indexed-checkout identity recorded with the DB. Legacy DBs record no - * such identity: expose the graph generation separately and fail closed as - * unknown rather than pretending the live checkout produced the graph. This - * is a report-only signal — it never triggers indexing. */ +/* BT-240: fail-closed freshness verdict against the indexed-checkout identity + * recorded with the DB at the successful staged-generation boundary. A live + * checkout SHA is not proof of the generation that produced graph content, so + * the verdict comes from that recorded identity: + * - no indexed SHA (legacy DB or non-git run) → unknown / unavailable + * - indexed SHA differs from the live git HEAD → stale / mismatch + * - equal → current + * Report-only: it never triggers indexing. Verbose-only and read-only; the + * live checkout is resolved fresh from the project root (never mutated). */ static void add_index_freshness_json(yyjson_mut_doc *doc, yyjson_mut_val *root, - const char *indexed_generation) { + cbm_store_t *store, const char *project, + const char *root_path, const char *indexed_generation) { yyjson_mut_val *freshness = yyjson_mut_obj(doc); if (indexed_generation && indexed_generation[0]) { yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_generation", indexed_generation); } else { yyjson_mut_obj_add_str(doc, freshness, "indexed_generation", ""); } - yyjson_mut_obj_add_null(doc, freshness, "indexed_checkout_sha"); - yyjson_mut_obj_add_str(doc, freshness, "verdict", "unknown"); - yyjson_mut_obj_add_str(doc, freshness, "reason", "indexed_checkout_unavailable"); + + cbm_coverage_meta_t meta = {0}; + bool have_meta = store && cbm_store_coverage_meta_get(store, project, &meta) == CBM_STORE_OK; + const char *indexed_sha = NULL; + if (have_meta && meta.indexed_checkout_sha && meta.indexed_checkout_sha[0]) { + indexed_sha = meta.indexed_checkout_sha; + } + + cbm_git_context_t ctx = {0}; + (void)cbm_git_context_resolve(root_path, &ctx); + const char *checkout_sha = ctx.head_sha && ctx.head_sha[0] ? ctx.head_sha : NULL; + + if (indexed_sha) { + yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_checkout_sha", indexed_sha); + } else { + yyjson_mut_obj_add_null(doc, freshness, "indexed_checkout_sha"); + } + if (checkout_sha) { + yyjson_mut_obj_add_strcpy(doc, freshness, "checkout_sha", checkout_sha); + } else { + yyjson_mut_obj_add_null(doc, freshness, "checkout_sha"); + } + + const char *verdict; + const char *reason; + const char *recommended_action; + if (!indexed_sha) { + verdict = "unknown"; + reason = "indexed_checkout_unavailable"; + recommended_action = "reindex_to_record_indexed_checkout"; + } else if (!checkout_sha || strcmp(indexed_sha, checkout_sha) != 0) { + verdict = "stale"; + reason = "indexed_checkout_mismatch"; + recommended_action = "reindex_to_match_checkout"; + } else { + verdict = "current"; + reason = "indexed_checkout_current"; + recommended_action = "use_graph"; + } + yyjson_mut_obj_add_str(doc, freshness, "verdict", verdict); + yyjson_mut_obj_add_str(doc, freshness, "reason", reason); + yyjson_mut_val *reasons = yyjson_mut_arr(doc); + yyjson_mut_arr_add_str(doc, reasons, reason); + yyjson_mut_obj_add_val(doc, freshness, "reasons", reasons); + yyjson_mut_obj_add_str(doc, freshness, "recommended_action", recommended_action); yyjson_mut_obj_add_val(doc, root, "freshness", freshness); + + if (have_meta) { + cbm_store_coverage_meta_clear(&meta); + } + cbm_git_context_free(&ctx); } static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { @@ -4502,7 +4553,8 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { proj_info.root_path ? proj_info.root_path : ""); if (verbose) { add_git_context_json(doc, root, proj_info.root_path); - add_index_freshness_json(doc, root, proj_info.indexed_at); + add_index_freshness_json(doc, root, store, project, proj_info.root_path, + proj_info.indexed_at); } safe_str_free(&proj_info.name); safe_str_free(&proj_info.indexed_at); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index a5b2647fc..91fc42f20 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1961,6 +1961,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas .ignored_files_total = p->ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, + .indexed_checkout_sha = p->git_ctx.head_sha, }, .surface_rows = p->surface_rows, .surface_row_count = p->surface_row_count, diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 3e4610677..fbaa3f842 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -2295,6 +2295,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .ignored_files_total = run_ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, + .indexed_checkout_sha = p->git_ctx.head_sha, }, .surface_rows = NULL, .surface_row_count = 0, @@ -2842,6 +2843,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .ignored_files_total = run_ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, + .indexed_checkout_sha = p->git_ctx.head_sha, }; /* Publish surfaces: the surviving previous rows plus this run's fresh * ones (closure route). The legacy test route publishes none — its diff --git a/src/store/store.c b/src/store/store.c index d91346e67..87a4f3a1f 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -225,6 +225,33 @@ static void iso_now(char *buf, size_t sz) { /* ── Schema ─────────────────────────────────────────────────────── */ +/* Best-effort probe for the BT-240 indexed_checkout_sha column. Returns true + * only when the index_coverage_meta table exists AND exposes the column. + * Writable opens run init_schema (which adds it), so false is expected only on + * read-only legacy DBs opened via the query path — those must keep working and + * simply report no indexed-checkout identity. */ +static bool coverage_meta_has_checkout_column(cbm_store_t *s) { + if (!s || !s->db) { + return false; + } + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, "PRAGMA table_info(index_coverage_meta);", CBM_NOT_FOUND, &stmt, + NULL) != SQLITE_OK) { + return false; + } + bool has = false; + int rc; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const char *name = (const char *)sqlite3_column_text(stmt, SKIP_ONE); + if (name && strcmp(name, "indexed_checkout_sha") == 0) { + has = true; + break; + } + } + sqlite3_finalize(stmt); + return has; +} + static int init_schema(cbm_store_t *s) { const char *ddl = "CREATE TABLE IF NOT EXISTS projects (" @@ -320,7 +347,8 @@ static int init_schema(cbm_store_t *s) { " ignored_files_stored INTEGER NOT NULL DEFAULT 0," " ignored_files_total INTEGER NOT NULL DEFAULT 0," " coverage_version INTEGER NOT NULL DEFAULT 1," - " hash_records_complete INTEGER NOT NULL DEFAULT 0" + " hash_records_complete INTEGER NOT NULL DEFAULT 0," + " indexed_checkout_sha TEXT" ");"; int rc = exec_sql(s, ddl); @@ -328,6 +356,16 @@ static int init_schema(cbm_store_t *s) { return rc; } + /* BT-240 migration: CREATE TABLE IF NOT EXISTS cannot add a column to an + * existing legacy index_coverage_meta, so add it idempotently for any + * writable DB that predates it. Read-only query opens skip init_schema + * entirely and meta_get probes the column separately. */ + if (!coverage_meta_has_checkout_column(s) && + exec_sql(s, "ALTER TABLE index_coverage_meta ADD COLUMN indexed_checkout_sha TEXT;") != + CBM_STORE_OK) { + return CBM_STORE_ERR; + } + /* Schema-compat probe (#768): DBs created before the local_name_gen * discriminator still enforce UNIQUE(source_id,target_id,type) and lack * the column — the widened upsert in cbm_store_insert_edge can neither @@ -2952,11 +2990,12 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, "INSERT INTO index_coverage_meta " "(project, generation, index_mode, recorded_at, recording_status, " " ignored_files_stored, ignored_files_total, coverage_version, " - " hash_records_complete) " - "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " + " hash_records_complete, indexed_checkout_sha) " + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) " "ON CONFLICT(project) DO UPDATE SET generation=?2, index_mode=?3, " "recorded_at=?4, recording_status=?5, ignored_files_stored=?6, " - "ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9;", + "ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9, " + "indexed_checkout_sha=?10;", CBM_NOT_FOUND, &up_meta, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "coverage meta upsert prepare"); (void)exec_sql(s, "ROLLBACK;"); @@ -2971,6 +3010,11 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, sqlite3_bind_int(up_meta, 7, ignored_total); sqlite3_bind_int(up_meta, 8, coverage_version); sqlite3_bind_int(up_meta, 9, meta->hash_records_complete ? 1 : 0); + if (meta->indexed_checkout_sha && meta->indexed_checkout_sha[0]) { + bind_text(up_meta, 10, meta->indexed_checkout_sha); + } else { + sqlite3_bind_null(up_meta, 10); + } int meta_rc = sqlite3_step(up_meta); sqlite3_finalize(up_meta); if (meta_rc != SQLITE_DONE) { @@ -3098,6 +3142,7 @@ void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) { free((char *)meta->index_mode); free((char *)meta->recorded_at); free((char *)meta->recording_status); + free((char *)meta->indexed_checkout_sha); memset(meta, 0, sizeof(*meta)); } @@ -3110,11 +3155,21 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag return CBM_STORE_ERR; } sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, - "SELECT project, generation, index_mode, recorded_at, recording_status, " - "ignored_files_stored, ignored_files_total, coverage_version, " - "hash_records_complete FROM index_coverage_meta WHERE project = ?1;", - CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + /* Legacy read-only DBs (query opens skip init_schema) lack the + * indexed_checkout_sha column — probe and fall back so they still read and + * report no identity instead of erroring. */ + static const char SQL_FULL[] = + "SELECT project, generation, index_mode, recorded_at, recording_status, " + "ignored_files_stored, ignored_files_total, coverage_version, " + "hash_records_complete, indexed_checkout_sha FROM index_coverage_meta " + "WHERE project = ?1;"; + static const char SQL_LEGACY[] = + "SELECT project, generation, index_mode, recorded_at, recording_status, " + "ignored_files_stored, ignored_files_total, coverage_version, " + "hash_records_complete FROM index_coverage_meta WHERE project = ?1;"; + bool has_checkout_column = coverage_meta_has_checkout_column(s); + if (sqlite3_prepare_v2(s->db, has_checkout_column ? SQL_FULL : SQL_LEGACY, CBM_NOT_FOUND, + &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "coverage meta get prepare"); return CBM_STORE_ERR; } @@ -3130,6 +3185,10 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag out->ignored_files_total = sqlite3_column_int(stmt, 6); out->coverage_version = sqlite3_column_int(stmt, 7); out->hash_records_complete = sqlite3_column_int(stmt, 8) != 0; + if (has_checkout_column) { + const char *sha = (const char *)sqlite3_column_text(stmt, 9); + out->indexed_checkout_sha = sha && sha[0] ? heap_strdup(sha) : NULL; + } sqlite3_finalize(stmt); if (!out->project || !out->generation || !out->index_mode || !out->recorded_at || !out->recording_status) { diff --git a/src/store/store.h b/src/store/store.h index 65f4971f8..c5a2e014a 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -546,6 +546,11 @@ typedef struct { int ignored_files_total; int coverage_version; bool hash_records_complete; + /* Git checkout identity frozen at the successful staged-generation + * boundary (BT-240). NULL/"" when the run had no git context (non-git + * repo) or the DB predates the column. Owned by the row: meta_get + * allocates it, meta_clear frees it. */ + const char *indexed_checkout_sha; } cbm_coverage_meta_t; /* Replace the project's coverage rows in one transaction, then prune rows for diff --git a/tests/test_mcp.c b/tests/test_mcp.c index e9d099b12..42d7ec17d 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -14,6 +14,7 @@ #include "test_framework.h" #include "test_helpers.h" #include +#include #include /* spawn-count hook — #845 in-process guard */ #include #include @@ -2692,9 +2693,141 @@ TEST(tool_index_status_omits_freshness_by_default) { PASS(); } -/* ══════════════════════════════════════════════════════════════════ - * TOOL HANDLERS WITH DATA - * ══════════════════════════════════════════════════════════════════ */ +/* ── BT-240: freshness verdict against the recorded indexed checkout ── + * These shell out to git, so they are skipped on Windows CI (the shell there + * cannot init a repo via system()). */ + +#ifndef _WIN32 +static int mcp_git_run(const char *dir, const char *args) { + char cmd[1024]; + snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s >/dev/null 2>&1", dir, args); + return system(cmd); +} + +static int mcp_make_git_repo(const char *dir) { + if (th_mkdir_p(dir) != 0) return -1; + if (mcp_git_run(dir, "init -q") != 0) return -1; + if (mcp_git_run(dir, "config user.email test@example.com") != 0) return -1; + if (mcp_git_run(dir, "config user.name Test") != 0) return -1; + if (th_write_file(TH_PATH(dir, ".keep"), "") != 0) return -1; + if (mcp_git_run(dir, "add -A") != 0) return -1; + if (mcp_git_run(dir, "commit -qm init") != 0) return -1; + return 0; +} +#endif + +/* Helper: build a server whose project root is the given git repo and whose + * coverage metadata records indexed_checkout_sha = sha (may be NULL). */ +#ifndef _WIN32 +static cbm_mcp_server_t *mcp_freshness_server(const char *repo, const char *sha) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) return NULL; + cbm_store_t *st = cbm_mcp_server_store(srv); + if (!st) { + cbm_mcp_server_free(srv); + return NULL; + } + cbm_mcp_server_set_project(srv, "fresh-project"); + cbm_store_upsert_project(st, "fresh-project", repo); + cbm_coverage_meta_t meta = { + .generation = "fresh-generation", + .index_mode = "fast", + .recorded_at = "2026-08-11T00:00:00Z", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true, + .indexed_checkout_sha = sha, + }; + if (cbm_store_coverage_replace_ex(st, "fresh-project", NULL, 0, &meta) != CBM_STORE_OK) { + cbm_mcp_server_free(srv); + return NULL; + } + return srv; +} +#endif + +TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches) { +#ifdef _WIN32 + SKIP_PLATFORM("git-based index_status freshness test not supported on Windows CI"); +#else + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"fresh-project\",\"verbose\":true}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"current\"")); + ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_current")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(inner, "\"checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"use_graph\"")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +#endif +} + +TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches) { +#ifdef _WIN32 + SKIP_PLATFORM("git-based index_status freshness test not supported on Windows CI"); +#else + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + + /* A stale recorded identity that does not equal the live HEAD. */ + cbm_mcp_server_t *srv = mcp_freshness_server(repo, "0000000000000000000000000000000000000000"); + if (!srv) { + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"fresh-project\",\"verbose\":true}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_mismatch")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":\"0000000000000000000000000000000000000000\"")); + ASSERT_NOT_NULL(strstr(inner, "\"checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"reindex_to_match_checkout\"")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + th_rmtree(repo); + PASS(); +#endif +} TEST(tool_trace_call_path_not_found) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -10593,6 +10726,8 @@ SUITE(mcp) { RUN_TEST(tool_index_status_includes_git_metadata); RUN_TEST(tool_index_status_fails_closed_without_indexed_checkout_identity); RUN_TEST(tool_index_status_omits_freshness_by_default); + RUN_TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches); /* Tool handlers with validation */ RUN_TEST(tool_trace_call_path_not_found); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index 64945d1be..d361928b0 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -2129,12 +2129,80 @@ TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails) { PASS(); } +TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "coverage-sha", "/tmp/coverage-sha"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-sha", "keep.c", "", 1, 1), CBM_STORE_OK); + cbm_coverage_row_t row = {.rel_path = "keep.c", .kind = "parse_partial", .detail = "1-2"}; + + /* NULL indexed_checkout_sha (non-git run) persists as NULL and reads back + * as no identity. */ + cbm_coverage_meta_t no_sha_meta = { + .generation = "gen-0", + .index_mode = "full", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true, + }; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &no_sha_meta), + CBM_STORE_OK); + cbm_coverage_meta_t got = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_NULL(got.indexed_checkout_sha); + cbm_store_coverage_meta_clear(&got); + + /* Round-trip: git-run identity persists and can be overwritten by the next + * staged generation. */ + cbm_coverage_meta_t sha_meta = no_sha_meta; + sha_meta.generation = "gen-1"; + sha_meta.indexed_checkout_sha = "0123456789abcdef0123456789abcdef01234567"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &sha_meta), + CBM_STORE_OK); + got = (cbm_coverage_meta_t){0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.indexed_checkout_sha, "0123456789abcdef0123456789abcdef01234567"); + ASSERT_STR_EQ(got.generation, "gen-1"); + cbm_store_coverage_meta_clear(&got); + + cbm_coverage_meta_t new_meta = sha_meta; + new_meta.generation = "gen-2"; + new_meta.indexed_checkout_sha = "abcdef0123456789abcdef0123456789abcdef01"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &new_meta), + CBM_STORE_OK); + got = (cbm_coverage_meta_t){0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.indexed_checkout_sha, "abcdef0123456789abcdef0123456789abcdef01"); + ASSERT_STR_EQ(got.generation, "gen-2"); + cbm_store_coverage_meta_clear(&got); + + /* Rollback: a failed replace must retain the prior identity + generation. */ + ASSERT_EQ(cbm_store_exec(s, "CREATE TRIGGER fail_sha_missed_insert BEFORE INSERT ON nodes " + "WHEN NEW.project = 'coverage-sha::missed' " + "BEGIN SELECT RAISE(ABORT, 'forced sha shadow failure'); END;"), + CBM_STORE_OK); + cbm_coverage_meta_t failed_meta = new_meta; + failed_meta.generation = "gen-must-not-commit"; + failed_meta.indexed_checkout_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &failed_meta), + CBM_STORE_ERR); + got = (cbm_coverage_meta_t){0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.generation, "gen-2"); + ASSERT_STR_EQ(got.indexed_checkout_sha, "abcdef0123456789abcdef0123456789abcdef01"); + cbm_store_coverage_meta_clear(&got); + + cbm_store_close(s); + PASS(); +} + SUITE(store_nodes) { RUN_TEST(store_coverage_roundtrip_prune_shadow); RUN_TEST(store_coverage_targeted_path_and_scope_lookup); RUN_TEST(store_coverage_meta_zero_row_truncation_and_delete); RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments); RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails); + RUN_TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback); RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like); RUN_TEST(store_open_memory); RUN_TEST(store_close_null); From 86fca02760ce7f68e85408d38abb55cf98a94541 Mon Sep 17 00:00:00 2001 From: Leonardo trindade miranda Date: Wed, 12 Aug 2026 00:15:49 -0300 Subject: [PATCH 3/5] feat(mcp): fail-closed verbose index_status via bounded git status Add cbm_git_worktree_status: parse git status --porcelain=v1 -z --untracked-files=all record-by-record over a bounded chunk buffer (4K chunks, 16K field cap). Counts advance for the whole stream while only max_samples paths per class (tracked/untracked) are retained; exceeding the cap sets *_truncated. Rename/copy records consume their second NUL-separated source path without counting it, so one rename counts once. available is true ONLY when git exited 0 and every record parsed cleanly; any shell-unsafe path, spawn failure, nonzero exit, malformed/oversized/trailing record leaves available=false with zero counts - callers must never read that as a clean worktree. Index the snapshot into the verbose freshness block (status_available, tracked_changes{count,paths,truncated}, untracked_source{...}). Verdict composition: clean matching SHA -> current; tracked changes or SHA mismatch -> stale; untracked-only, unavailable status or missing live HEAD -> unknown. Reasons array keeps every applicable code in stable order with the dominant reason first; recommended_action derives from the verdict. Report-only, verbose-only, read-only: no indexing or mutation is ever triggered. Owner gates corrected: restored missing #endif around canonical_root tests; rename/copy detection checks both XY columns; introduced cbm_pipeline_indexed_checkout_sha accessor because cbm_pipeline_t is opaque in pipeline_incremental.c (HEAD accessed git_ctx.head_sha directly and could not compile); Windows tests force git add -f so a user's global excludes cannot silently drop the tracked fixture; rollback test now mutates row detail so the shadow rebuild actually executes before the replace is rolled back. Tests now shell out through cbm_popen (isolated spawn) instead of system(), so the git-backed freshness/status tests run on Windows too; only a genuinely missing git skips. Prior SHA persistence (staged- generation boundary) is the anchor the new worktree status refines. Signed-off-by: Leonardo trindade miranda --- src/git/git_context.c | 218 ++++++++++++++++++++ src/git/git_context.h | 36 ++++ src/mcp/mcp.c | 119 +++++++++-- src/pipeline/pipeline.c | 4 + src/pipeline/pipeline_incremental.c | 4 +- src/pipeline/pipeline_internal.h | 2 + tests/test_git_context.c | 275 ++++++++++++++++++++++++- tests/test_mcp.c | 300 ++++++++++++++++++++++++++-- tests/test_store_nodes.c | 4 +- 9 files changed, 925 insertions(+), 37 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index f739c46e6..df8070a23 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -16,6 +16,17 @@ enum { GIT_OUTPUT_MAX = 4096, }; +/* Bounded worktree-status stream constants. + * STATUS_FIELD_MAX is the largest single NUL-delimited record field we + * buffer; a field at/over the bound is treated as a malformed stream and the + * whole snapshot fails closed. PATH components of real repos are far below + * this even with long paths. */ +enum { + STATUS_HEADER_LEN = 3, /* porcelain-v1 record prefix "XY " */ + STATUS_CHUNK_MAX = CBM_SZ_4K, + STATUS_FIELD_MAX = CBM_SZ_16K, +}; + static char *git_strdup(const char *s) { if (!s) { s = ""; @@ -409,3 +420,210 @@ int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_ } return off; } + +/* ── Bounded worktree status ────────────────────────────────────────────── + * git status --porcelain=v1 -z --untracked-files=all is parsed record by + * record, NUL-delimited, over a bounded chunk buffer. The full stream is + * counted but only max_samples paths per class (tracked / untracked) are + * ever retained, so a huge dirty tree cannot grow memory. Rename/copy + * records carry a second NUL-terminated path (the source); it is consumed + * without being counted so one rename counts once. */ + +typedef struct { + cbm_worktree_status_t *out; + int max_samples; + bool expecting_source; /* next field is the source path of an R/C record */ + bool parse_ok; + char partial[STATUS_FIELD_MAX]; + size_t partial_len; +} status_parser_t; + +/* porcelain-v1 status columns (X/Y): documented codes plus the lowercase + * submodule variants and type-change 'T'. Anything else is not a record + * header, so a malformed stream fails closed instead of being misread. */ +static bool status_code_ok(char c) { + switch (c) { + case ' ': + case 'M': + case 'A': + case 'D': + case 'R': + case 'C': + case 'U': + case '?': + case '!': + case 'T': + case 'm': + case 'a': + case 'd': + case 'r': + case 'c': + case 'u': + case 't': + return true; + default: + return false; + } +} + +/* Retain one bounded sample; the running total always advances so the caller + * sees the true stream count even when samples are capped or OOM. */ +static void status_add_sample(int *total, char ***samples, int *sample_count, + bool *truncated, int max_samples, const char *path, + size_t path_len) { + (*total)++; + if (*sample_count >= max_samples) { + *truncated = true; + return; + } + char *copy = (char *)malloc(path_len + 1); + if (!copy) { + *truncated = true; + return; + } + memcpy(copy, path, path_len); + copy[path_len] = '\0'; + char **grown = (char **)realloc(*samples, (size_t)(*sample_count + 1) * sizeof(char *)); + if (!grown) { + free(copy); + *truncated = true; + return; + } + *samples = grown; + (*samples)[*sample_count] = copy; + (*sample_count)++; +} + +/* Parse one complete NUL-delimited field. Returns false on malformed input, + * which fails the whole snapshot closed. */ +static bool status_parse_record(cbm_worktree_status_t *out, status_parser_t *parser, + const char *field, size_t field_len) { + if (parser->expecting_source) { + parser->expecting_source = false; + return true; /* second path of a rename/copy record — not counted */ + } + if (field_len < STATUS_HEADER_LEN || field[2] != ' ' || + !status_code_ok(field[0]) || !status_code_ok(field[1])) { + return false; + } + const char *path = field + STATUS_HEADER_LEN; + size_t path_len = field_len - STATUS_HEADER_LEN; + if (field[0] == '?' && field[1] == '?') { + status_add_sample(&out->untracked_count, &out->untracked_paths, + &out->untracked_sample_count, &out->untracked_truncated, + parser->max_samples, path, path_len); + return true; + } + if (field[0] == 'R' || field[0] == 'C' || field[1] == 'R' || field[1] == 'C') { + parser->expecting_source = true; + } + status_add_sample(&out->tracked_count, &out->tracked_paths, + &out->tracked_sample_count, &out->tracked_truncated, + parser->max_samples, path, path_len); + return true; +} + +/* Feed a chunk of the NUL-delimited stream through the bounded record parser. */ +static void status_feed(status_parser_t *parser, const char *data, size_t data_len) { + size_t i = 0; + while (i < data_len) { + if (parser->partial_len >= STATUS_FIELD_MAX) { + parser->parse_ok = false; /* field exceeded the bound: malformed */ + return; + } + size_t j = i; + while (j < data_len && data[j] != '\0' && + parser->partial_len + (j - i) < STATUS_FIELD_MAX) { + j++; + } + size_t take = j - i; + memcpy(parser->partial + parser->partial_len, data + i, take); + parser->partial_len += take; + i += take; + if (i < data_len && data[i] == '\0') { + if (!status_parse_record(parser->out, parser, parser->partial, + parser->partial_len)) { + parser->parse_ok = false; + return; + } + parser->partial_len = 0; + i++; /* consume the record terminator */ + } else if (i >= data_len) { + return; /* chunk exhausted mid-record */ + } + } +} + +int cbm_git_worktree_status(const char *validated_root, int max_samples, + cbm_worktree_status_t *out) { + if (!out) { + return CBM_NOT_FOUND; + } + memset(out, 0, sizeof(*out)); + if (!validated_root || !validated_root[0] || max_samples < 0) { + return CBM_NOT_FOUND; + } + if (!git_validate_repo_path(validated_root)) { + return CBM_NOT_FOUND; + } + + char cmd[GIT_CMD_MAX]; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + int n = snprintf(cmd, sizeof(cmd), + "git -C \"%s\" status --porcelain=v1 -z --untracked-files=all 2>%s", + validated_root, null_dev); + if (n < 0 || n >= (int)sizeof(cmd)) { + return CBM_NOT_FOUND; + } + + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return 0; /* out is zeroed: available=false */ + } + + status_parser_t parser; + memset(&parser, 0, sizeof(parser)); + parser.out = out; + parser.max_samples = max_samples; + parser.parse_ok = true; + + char chunk[STATUS_CHUNK_MAX]; + size_t got; + bool io_error = false; + while (parser.parse_ok && (got = fread(chunk, 1, sizeof(chunk), fp)) > 0) { + status_feed(&parser, chunk, got); + } + if (ferror(fp)) { + io_error = true; + } + int rc = cbm_pclose(fp); + + if (rc != 0 || !parser.parse_ok || io_error || parser.partial_len > 0) { + /* Fail closed: any deviation from a clean, complete status stream is a + * machine-readable "unavailable", never a clean worktree. */ + cbm_git_worktree_status_free(out); + return 0; + } + + out->available = true; + return 0; +} + +void cbm_git_worktree_status_free(cbm_worktree_status_t *st) { + if (!st) { + return; + } + for (int i = 0; i < st->tracked_sample_count; i++) { + free(st->tracked_paths[i]); + } + free(st->tracked_paths); + for (int i = 0; i < st->untracked_sample_count; i++) { + free(st->untracked_paths[i]); + } + free(st->untracked_paths); + memset(st, 0, sizeof(*st)); +} diff --git a/src/git/git_context.h b/src/git/git_context.h index 876309eb6..2caa20bba 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -24,4 +24,40 @@ void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_size); +/* Bounded snapshot of the git worktree status for one validated repo root. + * + * available is true ONLY when `git status --porcelain=v1 -z --untracked-files=all` + * exited 0 AND every NUL-delimited record parsed cleanly. Any other outcome — + * shell-unsafe path, spawn failure, nonzero exit, malformed/oversized record, + * trailing partial record — leaves available=false with zero counts and no + * samples. Callers MUST treat !available as "status unavailable", never as a + * clean worktree. + * + * The entire stream is counted, but only max_samples paths per class (tracked + * / untracked) are ever retained, in stream order; exceeding the cap sets the + * matching *_truncated flag. Rename/copy records are counted once (their two + * NUL-separated paths are consumed without counting the second), and the + * sampled path is the record's NEW path. Ignored files never appear because + * --ignored is not requested. + * + * cbm_git_worktree_status returns 0 when *out has been filled (available tells + * whether the snapshot succeeded) and CBM_NOT_FOUND for invalid arguments + * (NULL root/out or max_samples < 0). The caller owns the returned samples via + * cbm_git_worktree_status_free. */ +typedef struct { + bool available; + int tracked_count; + int untracked_count; + char **tracked_paths; /* heap-owned samples, tracked_sample_count entries */ + int tracked_sample_count; + bool tracked_truncated; + char **untracked_paths; + int untracked_sample_count; + bool untracked_truncated; +} cbm_worktree_status_t; + +int cbm_git_worktree_status(const char *validated_root, int max_samples, + cbm_worktree_status_t *out); +void cbm_git_worktree_status_free(cbm_worktree_status_t *st); + #endif diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 4a596504e..f218c47a2 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -36,6 +36,8 @@ enum { MCP_TOOLS_PAGE_SIZE = 8, MCP_HELP_TOOLS_WRAP_COL = 74, /* --help tool list stays readable on 80-col terminals */ MCP_MAX_CROSS_REPO_TARGETS = 4096, + MCP_STATUS_SAMPLE_MAX = 16, /* per-class path samples in verbose freshness */ + MCP_REASONS_MAX = 8, /* freshness reasons array ceiling (see below) */ }; #define MCP_MS_TO_US 1000LL #define MCP_S_TO_US 1000000LL @@ -4456,13 +4458,47 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args return result; } +/* Emit the bounded worktree-status snapshot inside the freshness block. + * Fail-closed: status_available is false whenever git status could not run + * clean, in which case the API guarantees zero counts and no samples — a + * non-git root or missing git is NEVER presented as a clean worktree. Ignored + * paths are deliberately absent (no --ignored in the underlying query) and + * must not be confused with tracked/untracked sources. */ +static void add_worktree_status_json(yyjson_mut_doc *doc, yyjson_mut_val *freshness, + const cbm_worktree_status_t *st) { + yyjson_mut_obj_add_bool(doc, freshness, "status_available", st->available); + + yyjson_mut_val *tracked = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, tracked, "count", st->tracked_count); + yyjson_mut_val *tpaths = yyjson_mut_arr(doc); + for (int i = 0; i < st->tracked_sample_count; i++) { + yyjson_mut_arr_add_strcpy(doc, tpaths, st->tracked_paths[i]); + } + yyjson_mut_obj_add_val(doc, tracked, "paths", tpaths); + yyjson_mut_obj_add_bool(doc, tracked, "truncated", st->tracked_truncated); + yyjson_mut_obj_add_val(doc, freshness, "tracked_changes", tracked); + + yyjson_mut_val *untracked = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, untracked, "count", st->untracked_count); + yyjson_mut_val *upaths = yyjson_mut_arr(doc); + for (int i = 0; i < st->untracked_sample_count; i++) { + yyjson_mut_arr_add_strcpy(doc, upaths, st->untracked_paths[i]); + } + yyjson_mut_obj_add_val(doc, untracked, "paths", upaths); + yyjson_mut_obj_add_bool(doc, untracked, "truncated", st->untracked_truncated); + yyjson_mut_obj_add_val(doc, freshness, "untracked_source", untracked); +} + /* BT-240: fail-closed freshness verdict against the indexed-checkout identity * recorded with the DB at the successful staged-generation boundary. A live * checkout SHA is not proof of the generation that produced graph content, so - * the verdict comes from that recorded identity: - * - no indexed SHA (legacy DB or non-git run) → unknown / unavailable - * - indexed SHA differs from the live git HEAD → stale / mismatch - * - equal → current + * the verdict comes from that recorded identity, the live git HEAD and the + * bounded worktree status (tracked/untracked deltas): + * - no indexed SHA → unknown / unavailable + * - indexed SHA differs from the live git HEAD → stale / mismatch + * - tracked changes present → stale / tracked + * - untracked sources present → prevents current + * - equal SHA + status available + no changes → current * Report-only: it never triggers indexing. Verbose-only and read-only; the * live checkout is resolved fresh from the project root (never mutated). */ static void add_index_freshness_json(yyjson_mut_doc *doc, yyjson_mut_val *root, @@ -4486,6 +4522,11 @@ static void add_index_freshness_json(yyjson_mut_doc *doc, yyjson_mut_val *root, (void)cbm_git_context_resolve(root_path, &ctx); const char *checkout_sha = ctx.head_sha && ctx.head_sha[0] ? ctx.head_sha : NULL; + cbm_worktree_status_t st = {0}; + int status_rc = cbm_git_worktree_status(root_path, MCP_STATUS_SAMPLE_MAX, &st); + bool status_available = status_rc == 0 && st.available; + add_worktree_status_json(doc, freshness, &st); + if (indexed_sha) { yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_checkout_sha", indexed_sha); } else { @@ -4497,33 +4538,79 @@ static void add_index_freshness_json(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_obj_add_null(doc, freshness, "checkout_sha"); } + /* Verdict/reasons composition. Stale reasons (identity mismatch, tracked + * changes) win over everything; a missing indexed identity is unknown + * regardless of live state; status unavailability and untracked sources + * both prevent "current" but only yield stale when a stale reason is + * already present. The reasons array keeps every applicable code in a + * stable order; the singular reason stays as the first (dominant) one for + * compatibility with consumers that read it directly. */ + bool sha_present = indexed_sha != NULL; + bool live_present = checkout_sha != NULL; + bool sha_mismatch = sha_present && live_present && strcmp(indexed_sha, checkout_sha) != 0; + bool has_tracked = st.tracked_count > 0; + bool has_untracked = st.untracked_count > 0; + bool has_stale = sha_mismatch || has_tracked; + const char *verdict; - const char *reason; - const char *recommended_action; - if (!indexed_sha) { + if (!sha_present) { verdict = "unknown"; - reason = "indexed_checkout_unavailable"; - recommended_action = "reindex_to_record_indexed_checkout"; - } else if (!checkout_sha || strcmp(indexed_sha, checkout_sha) != 0) { + } else if (has_stale) { verdict = "stale"; - reason = "indexed_checkout_mismatch"; - recommended_action = "reindex_to_match_checkout"; + } else if (!live_present) { + verdict = "unknown"; + } else if (!status_available) { + verdict = "unknown"; + } else if (has_untracked) { + verdict = "unknown"; } else { verdict = "current"; - reason = "indexed_checkout_current"; + } + + const char *reasons[MCP_REASONS_MAX]; + int n_reasons = 0; + if (!sha_present) { + reasons[n_reasons++] = "indexed_checkout_unavailable"; + } + if (sha_mismatch) { + reasons[n_reasons++] = "indexed_checkout_mismatch"; + } + if (has_tracked) { + reasons[n_reasons++] = "tracked_changes_present"; + } + if (!status_available) { + reasons[n_reasons++] = "status_unavailable"; + } + if (has_untracked) { + reasons[n_reasons++] = "untracked_not_indexed"; + } + if (n_reasons == 0) { + reasons[n_reasons++] = "indexed_checkout_current"; + } + const char *reason = reasons[0]; + + const char *recommended_action; + if (strcmp(verdict, "current") == 0) { recommended_action = "use_graph"; + } else if (strcmp(verdict, "stale") == 0) { + recommended_action = "reindex_to_match_checkout"; + } else { + recommended_action = "reindex_to_record_indexed_checkout"; } yyjson_mut_obj_add_str(doc, freshness, "verdict", verdict); yyjson_mut_obj_add_str(doc, freshness, "reason", reason); - yyjson_mut_val *reasons = yyjson_mut_arr(doc); - yyjson_mut_arr_add_str(doc, reasons, reason); - yyjson_mut_obj_add_val(doc, freshness, "reasons", reasons); + yyjson_mut_val *reasons_val = yyjson_mut_arr(doc); + for (int i = 0; i < n_reasons; i++) { + yyjson_mut_arr_add_str(doc, reasons_val, reasons[i]); + } + yyjson_mut_obj_add_val(doc, freshness, "reasons", reasons_val); yyjson_mut_obj_add_str(doc, freshness, "recommended_action", recommended_action); yyjson_mut_obj_add_val(doc, root, "freshness", freshness); if (have_meta) { cbm_store_coverage_meta_clear(&meta); } + cbm_git_worktree_status_free(&st); cbm_git_context_free(&ctx); } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 91fc42f20..9f48a8191 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -411,6 +411,10 @@ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p) { return p ? p->repo_path : NULL; } +const char *cbm_pipeline_indexed_checkout_sha(const cbm_pipeline_t *p) { + return p ? p->git_ctx.head_sha : NULL; +} + atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p) { return p ? p->cancelled : NULL; } diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index fbaa3f842..ed2ebf74a 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -2295,7 +2295,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .ignored_files_total = run_ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, - .indexed_checkout_sha = p->git_ctx.head_sha, + .indexed_checkout_sha = cbm_pipeline_indexed_checkout_sha(p), }, .surface_rows = NULL, .surface_row_count = 0, @@ -2843,7 +2843,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .ignored_files_total = run_ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, - .indexed_checkout_sha = p->git_ctx.head_sha, + .indexed_checkout_sha = cbm_pipeline_indexed_checkout_sha(p), }; /* Publish surfaces: the surviving previous rows plus this run's fresh * ones (closure route). The legacy test route publishes none — its diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 50efd3ba5..402f1bc5c 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -765,6 +765,8 @@ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *row /* Pipeline accessors for incremental use */ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p); +/* Borrowed checkout identity captured and validated for this pipeline run. */ +const char *cbm_pipeline_indexed_checkout_sha(const cbm_pipeline_t *p); atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p); /* Record committed graph size (#334 gate axis) from the incremental path, * which cannot see the opaque cbm_pipeline struct. Call before the dump. */ diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a5..3a2c5ebd4 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -8,8 +8,10 @@ * (input_path), not to worktree_root. Joining it with worktree_root and then * string-stripping "/.git" left unresolved ".." components in the result. * - * These tests shell out to `git`, so they SKIP_PLATFORM on Windows (the CI - * shell there cannot init a repo via system()). + * These tests shell out to `git`, so the canonical_root tests SKIP_PLATFORM on + * Windows (the CI shell there cannot init a repo via system()). The bounded + * worktree-status tests below shell out through cbm_popen instead, so they run + * on every platform that has git on PATH. * * Reproduce-first guard: canonical_root_subdir is the genuine RED-without-the-fix * guard — a repo indexed from a subdirectory yields a relative --git-common-dir @@ -232,10 +234,279 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +/* ── Bounded worktree status (BT-240 increment 3) ────────────────────── + * These run on EVERY platform, not just POSIX: they shell out through + * cbm_popen (the same isolated spawn production git_context uses) instead of + * system(), so they work whenever git is on PATH — Windows CI included. The + * core status parser is platform-neutral and must be exercised everywhere git + * is available; only a genuinely missing git skips. */ + +static int status_git_run(const char *dir, const char *args) { + char cmd[1024]; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", dir, args, null_dev); + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return -1; + } + char drain[256]; + while (fgets(drain, sizeof(drain), fp)) { + } + return cbm_pclose(fp); +} + +static int status_make_repo(const char *dir) { + if (th_mkdir_p(dir) != 0) return -1; + if (status_git_run(dir, "init -q") != 0) return -1; + if (status_git_run(dir, "config user.email test@example.com") != 0) return -1; + if (status_git_run(dir, "config user.name Test") != 0) return -1; + if (th_write_file(TH_PATH(dir, ".keep"), "") != 0) return -1; + if (status_git_run(dir, "add -A") != 0) return -1; + if (status_git_run(dir, "commit -qm init") != 0) return -1; + return 0; +} + +TEST(worktree_status_clean) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("clean repo status not available"); + } + ASSERT_EQ(st.tracked_count, 0); + ASSERT_EQ(st.untracked_count, 0); + ASSERT_EQ(st.tracked_sample_count, 0); + ASSERT_EQ(st.untracked_sample_count, 0); + ASSERT_FALSE(st.tracked_truncated); + ASSERT_FALSE(st.untracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_tracked_modified) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 || + status_git_run(tmp, "add -f -- tracked.txt") != 0 || + status_git_run(tmp, "commit -qm add tracked") != 0) { + th_rmtree(tmp); + FAIL("failed to commit tracked file"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v2") != 0) { + th_rmtree(tmp); + FAIL("failed to modify tracked file"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.tracked_count, 1); + ASSERT_EQ(st.untracked_count, 0); + ASSERT_EQ(st.tracked_sample_count, 1); + ASSERT_STR_EQ(st.tracked_paths[0], "tracked.txt"); + ASSERT_FALSE(st.tracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_untracked) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "newfile.txt"), "n") != 0) { + th_rmtree(tmp); + FAIL("failed to create untracked file"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.untracked_count, 1); + ASSERT_EQ(st.tracked_count, 0); + ASSERT_EQ(st.untracked_sample_count, 1); + ASSERT_STR_EQ(st.untracked_paths[0], "newfile.txt"); + ASSERT_FALSE(st.untracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_rename_counts_once) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "old.txt"), "o") != 0 || + th_write_file(TH_PATH(tmp, "other.txt"), "x") != 0 || + status_git_run(tmp, "add -A") != 0 || + status_git_run(tmp, "commit -qm base") != 0 || + status_git_run(tmp, "mv old.txt new.txt") != 0) { + th_rmtree(tmp); + FAIL("failed to stage a rename"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.tracked_count, 1); /* rename/copy counts ONCE, not twice */ + ASSERT_EQ(st.tracked_sample_count, 1); + ASSERT_STR_EQ(st.tracked_paths[0], "new.txt"); /* sampled NEW path */ + ASSERT_EQ(st.untracked_count, 0); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_sample_cap_truncates) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + enum { N_FILES = 5, SAMPLE_MAX = 2 }; + for (int i = 0; i < N_FILES; i++) { + char path[1024]; + snprintf(path, sizeof(path), "%s/u%d.txt", tmp, i); + if (th_write_file(path, "u") != 0) { + th_rmtree(tmp); + FAIL("failed to create untracked file"); + } + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, SAMPLE_MAX, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.untracked_count, N_FILES); /* full stream counted... */ + ASSERT_EQ(st.untracked_sample_count, SAMPLE_MAX); /* ...samples bounded */ + ASSERT_TRUE(st.untracked_truncated); + ASSERT_EQ(st.tracked_count, 0); + ASSERT_FALSE(st.tracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_mixed_tracked_and_untracked) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 || + status_git_run(tmp, "add -f -- tracked.txt") != 0 || + status_git_run(tmp, "commit -qm add tracked") != 0) { + th_rmtree(tmp); + FAIL("failed to commit tracked file"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v2") != 0 || + th_write_file(TH_PATH(tmp, "untracked.txt"), "n") != 0) { + th_rmtree(tmp); + FAIL("failed to prepare mixed worktree"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.tracked_count, 1); + ASSERT_EQ(st.untracked_count, 1); + ASSERT_STR_EQ(st.tracked_paths[0], "tracked.txt"); + ASSERT_STR_EQ(st.untracked_paths[0], "untracked.txt"); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +/* Fail-closed contract: a non-repo directory must report unavailable with + * zero counts — never a silent "clean" tree. Does not need git on PATH. */ +TEST(worktree_status_unavailable_non_repo) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0) { + th_rmtree(tmp); + FAIL("status call returned an error"); + } + ASSERT_FALSE(st.available); + ASSERT_EQ(st.tracked_count, 0); + ASSERT_EQ(st.untracked_count, 0); + ASSERT_EQ(st.tracked_sample_count, 0); + ASSERT_EQ(st.untracked_sample_count, 0); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(worktree_status_clean); + RUN_TEST(worktree_status_tracked_modified); + RUN_TEST(worktree_status_untracked); + RUN_TEST(worktree_status_rename_counts_once); + RUN_TEST(worktree_status_sample_cap_truncates); + RUN_TEST(worktree_status_mixed_tracked_and_untracked); + RUN_TEST(worktree_status_unavailable_non_repo); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 42d7ec17d..07fe46da7 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2685,6 +2685,11 @@ TEST(tool_index_status_omits_freshness_by_default) { ASSERT_NOT_NULL(strstr(inner, "\"status\"")); ASSERT_NULL(strstr(inner, "\"freshness\"")); ASSERT_NULL(strstr(inner, "indexed_checkout_sha")); + /* BT-240 increment 3: the worktree-status snapshot is part of the + * verbose-only freshness block and must not leak into the default call. */ + ASSERT_NULL(strstr(inner, "status_available")); + ASSERT_NULL(strstr(inner, "tracked_changes")); + ASSERT_NULL(strstr(inner, "untracked_source")); free(inner); free(resp); @@ -2694,14 +2699,27 @@ TEST(tool_index_status_omits_freshness_by_default) { } /* ── BT-240: freshness verdict against the recorded indexed checkout ── - * These shell out to git, so they are skipped on Windows CI (the shell there - * cannot init a repo via system()). */ + * These shell out to git via cbm_popen (the same isolated spawn production + * git_context uses) rather than system(), so they run on every platform that + * has git on PATH — Windows CI included. A genuinely missing git is the only + * skip. */ -#ifndef _WIN32 static int mcp_git_run(const char *dir, const char *args) { char cmd[1024]; - snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s >/dev/null 2>&1", dir, args); - return system(cmd); +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", dir, args, null_dev); + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return -1; + } + char drain[256]; + while (fgets(drain, sizeof(drain), fp)) { + } + return cbm_pclose(fp); } static int mcp_make_git_repo(const char *dir) { @@ -2714,11 +2732,9 @@ static int mcp_make_git_repo(const char *dir) { if (mcp_git_run(dir, "commit -qm init") != 0) return -1; return 0; } -#endif /* Helper: build a server whose project root is the given git repo and whose * coverage metadata records indexed_checkout_sha = sha (may be NULL). */ -#ifndef _WIN32 static cbm_mcp_server_t *mcp_freshness_server(const char *repo, const char *sha) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); if (!srv) return NULL; @@ -2744,12 +2760,8 @@ static cbm_mcp_server_t *mcp_freshness_server(const char *repo, const char *sha) } return srv; } -#endif TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches) { -#ifdef _WIN32 - SKIP_PLATFORM("git-based index_status freshness test not supported on Windows CI"); -#else char *repo = th_mktempdir("cbm_fresh_git"); if (!repo) FAIL("th_mktempdir returned NULL"); if (mcp_make_git_repo(repo) != 0) { @@ -2782,6 +2794,10 @@ TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches) ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":\"")); ASSERT_NOT_NULL(strstr(inner, "\"checkout_sha\":\"")); ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"use_graph\"")); + ASSERT_NOT_NULL(strstr(inner, "\"status_available\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":")); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":0")); free(inner); free(resp); @@ -2789,13 +2805,9 @@ TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches) free(head); th_rmtree(repo); PASS(); -#endif } TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches) { -#ifdef _WIN32 - SKIP_PLATFORM("git-based index_status freshness test not supported on Windows CI"); -#else char *repo = th_mktempdir("cbm_fresh_git"); if (!repo) FAIL("th_mktempdir returned NULL"); if (mcp_make_git_repo(repo) != 0) { @@ -2826,7 +2838,258 @@ TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches) cbm_mcp_server_free(srv); th_rmtree(repo); PASS(); -#endif +} + +/* BT-240 increment 3: a modified tracked file makes the verdict stale even + * when the indexed SHA matches the live HEAD, with the sample surfaced. */ +TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 || + mcp_git_run(repo, "add -f -- tracked.txt") != 0 || + mcp_git_run(repo, "commit -qm add tracked") != 0) { + th_rmtree(repo); + FAIL("failed to commit tracked file"); + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v2") != 0) { + free(head); + th_rmtree(repo); + FAIL("failed to modify tracked file"); + } + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"fresh-project\",\"verbose\":true}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(inner, "tracked_changes_present")); + ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":1")); + ASSERT_NOT_NULL(strstr(inner, "\"paths\":[\"tracked.txt\"]")); + ASSERT_NOT_NULL(strstr(inner, "\"truncated\":false")); + ASSERT_NULL(strstr(inner, "indexed_checkout_current")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: untracked sources prevent "current" — verdict is + * unknown (no stale reason), with the untracked_not_indexed reason. */ +TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(repo, "newfile.txt"), "n") != 0) { + th_rmtree(repo); + FAIL("failed to create untracked file"); + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"fresh-project\",\"verbose\":true}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\"")); + ASSERT_NOT_NULL(strstr(inner, "untracked_not_indexed")); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":1")); + ASSERT_NULL(strstr(inner, "indexed_checkout_current")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: tracked changes win over untracked — both reasons are + * present but the verdict is stale, never unknown. */ +TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 || + mcp_git_run(repo, "add -f -- tracked.txt") != 0 || + mcp_git_run(repo, "commit -qm add tracked") != 0) { + th_rmtree(repo); + FAIL("failed to commit tracked file"); + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v2") != 0 || + th_write_file(TH_PATH(repo, "untracked.txt"), "n") != 0) { + free(head); + th_rmtree(repo); + FAIL("failed to prepare mixed worktree"); + } + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"fresh-project\",\"verbose\":true}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(inner, "tracked_changes_present")); + ASSERT_NOT_NULL(strstr(inner, "untracked_not_indexed")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: a non-git root with a recorded identity cannot prove + * current — status availability must be false and the verdict unknown. */ +TEST(tool_index_status_freshness_status_unavailable_non_git) { + char *repo = th_mktempdir("cbm_fresh_non_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + + /* Record an identity even though the root is not a git repository. */ + cbm_mcp_server_t *srv = mcp_freshness_server(repo, "0123456789012345678901234567890123456789"); + if (!srv) { + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"fresh-project\",\"verbose\":true}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"status_available\":false")); + ASSERT_NOT_NULL(strstr(inner, "status_unavailable")); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\"")); + ASSERT_NULL(strstr(inner, "indexed_checkout_current")); + ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":")); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":0")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: sample cap at the MCP layer — many untracked sources + * surface the true count, a bounded paths array and truncated=true. */ +TEST(tool_index_status_freshness_sample_cap_truncates) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + enum { N_FILES = 17, MCP_SAMPLE_MAX = 16 }; + for (int i = 0; i < N_FILES; i++) { + char path[1024]; + snprintf(path, sizeof(path), "%s/u%02d.txt", repo, i); + if (th_write_file(path, "u") != 0) { + th_rmtree(repo); + FAIL("failed to create untracked file"); + } + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"fresh-project\",\"verbose\":true}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":17")); + ASSERT_NOT_NULL(strstr(inner, "\"truncated\":true")); + char sample_buf[128]; + snprintf(sample_buf, sizeof(sample_buf), "\"paths\":[\"u00.txt\",\"u01.txt\",\"u02.txt\",\"u03.txt\",\"u04.txt\",\"u05.txt\",\"u06.txt\",\"u07.txt\",\"u08.txt\",\"u09.txt\",\"u10.txt\",\"u11.txt\",\"u12.txt\",\"u13.txt\",\"u14.txt\",\"u15.txt\"]"); + ASSERT_NOT_NULL(strstr(inner, sample_buf)); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); } TEST(tool_trace_call_path_not_found) { @@ -10728,6 +10991,11 @@ SUITE(mcp) { RUN_TEST(tool_index_status_omits_freshness_by_default); RUN_TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches); RUN_TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes); + RUN_TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked); + RUN_TEST(tool_index_status_freshness_status_unavailable_non_git); + RUN_TEST(tool_index_status_freshness_sample_cap_truncates); /* Tool handlers with validation */ RUN_TEST(tool_trace_call_path_not_found); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index d361928b0..be314acdd 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -2184,7 +2184,9 @@ TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback) { cbm_coverage_meta_t failed_meta = new_meta; failed_meta.generation = "gen-must-not-commit"; failed_meta.indexed_checkout_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; - ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &failed_meta), + cbm_coverage_row_t failed_row = row; + failed_row.detail = "changed-to-force-shadow-rebuild"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &failed_row, 1, &failed_meta), CBM_STORE_ERR); got = (cbm_coverage_meta_t){0}; ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); From 167783bbcf4630bc7161829f569a716095426325 Mon Sep 17 00:00:00 2001 From: Leonardo trindade miranda Date: Wed, 12 Aug 2026 00:24:41 -0300 Subject: [PATCH 4/5] test(git): avoid ambiguous commit message pathspec Signed-off-by: Leonardo trindade miranda --- tests/test_git_context.c | 4 ++-- tests/test_mcp.c | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_git_context.c b/tests/test_git_context.c index 3a2c5ebd4..3e6ebc113 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -308,7 +308,7 @@ TEST(worktree_status_tracked_modified) { } if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 || status_git_run(tmp, "add -f -- tracked.txt") != 0 || - status_git_run(tmp, "commit -qm add tracked") != 0) { + status_git_run(tmp, "commit -q -m add-tracked") != 0) { th_rmtree(tmp); FAIL("failed to commit tracked file"); } @@ -446,7 +446,7 @@ TEST(worktree_status_mixed_tracked_and_untracked) { } if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 || status_git_run(tmp, "add -f -- tracked.txt") != 0 || - status_git_run(tmp, "commit -qm add tracked") != 0) { + status_git_run(tmp, "commit -q -m add-tracked") != 0) { th_rmtree(tmp); FAIL("failed to commit tracked file"); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 07fe46da7..65831251e 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2851,7 +2851,7 @@ TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes) { } if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 || mcp_git_run(repo, "add -f -- tracked.txt") != 0 || - mcp_git_run(repo, "commit -qm add tracked") != 0) { + mcp_git_run(repo, "commit -q -m add-tracked") != 0) { th_rmtree(repo); FAIL("failed to commit tracked file"); } @@ -2959,7 +2959,7 @@ TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked) { } if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 || mcp_git_run(repo, "add -f -- tracked.txt") != 0 || - mcp_git_run(repo, "commit -qm add tracked") != 0) { + mcp_git_run(repo, "commit -q -m add-tracked") != 0) { th_rmtree(repo); FAIL("failed to commit tracked file"); } From 038194e7bd11d618d420a69e227e1f2eb0646d7b Mon Sep 17 00:00:00 2001 From: Leonardo trindade miranda Date: Wed, 12 Aug 2026 18:18:07 -0300 Subject: [PATCH 5/5] feat(daemon): let a product build relocate the runtime directory The daemon/CLI rendezvous is created under %LOCALAPPDATA% (Windows) or the POSIX runtime/home directory, resolved with no override in a product build. When that ancestry carries a mutation-granting ACE for an untrusted identity -- an AppContainer capability SID, for instance -- it fails cbm_daemon_ipc_private_directory_secure() and the binary cannot start at all. config list is unreachable too, because it needs the same endpoint, so the operator cannot even inspect settings to diagnose it. The only relocation hook, CBM_TEST_DAEMON_RUNTIME_PARENT, is compiled out unless CBM_ENABLE_TEST_SEAMS is defined, so a test build starts while the product build does not. CBM_RUNTIME_DIR does not relax the check: the named directory goes through exactly the same validation, and an unusable value is refused rather than ignored. The operator only chooses an ancestry that passes. The test seam keeps precedence so existing lifecycle tests keep their isolation, and the override is applied at every product endpoint call site -- daemon, local CLI, tool execution and index worker -- so the CLI path cannot silently keep the default. Refs #1574 Signed-off-by: Leonardo trindade miranda --- src/main.c | 37 +++-- tests/test_product_runtime_dir_override.py | 154 +++++++++++++++++++++ 2 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 tests/test_product_runtime_dir_override.py diff --git a/src/main.c b/src/main.c index 574002b6a..fd0ca3c61 100644 --- a/src/main.c +++ b/src/main.c @@ -1260,19 +1260,38 @@ static uint64_t main_deadline_after(uint32_t timeout_ms) { return now_ms > UINT64_MAX - timeout_ms ? UINT64_MAX : now_ms + timeout_ms; } -static cbm_daemon_ipc_endpoint_t *main_daemon_endpoint_new(void) { +/* The rendezvous directory lives under %LOCALAPPDATA% (Windows) or the POSIX + * runtime/home directory by default. That ancestry is not always acceptable to + * cbm_daemon_ipc_private_directory_secure(): a profile that has acquired a + * mutation-granting ACE for an untrusted identity -- an AppContainer capability + * SID, for instance -- fails it, and the binary then cannot start at all, with + * no supported way to point it elsewhere. `config` is unreachable too, because + * it needs the same endpoint. + * + * CBM_RUNTIME_DIR does NOT relax that check. The directory it names goes + * through exactly the same validation; the operator merely chooses an ancestry + * that passes. An unset or rejected value keeps the default behavior. */ +static const char *main_runtime_parent(char *buffer, size_t capacity) { const char *runtime_parent = NULL; #ifdef CBM_ENABLE_TEST_SEAMS /* Product daemon coordination is deliberately account-wide. Product-level * lifecycle guards need an isolated rendezvous namespace so they cannot * attach to or retire a developer's real daemon while exercising exact * start/open/stop behavior. The seam is opt-in at compile time and the - * detached child inherits the same environment value. */ - char seam_runtime_parent[MAIN_PATH_CAP]; - runtime_parent = cbm_safe_getenv("CBM_TEST_DAEMON_RUNTIME_PARENT", seam_runtime_parent, - sizeof(seam_runtime_parent), NULL); + * detached child inherits the same environment value. It wins over the + * product override so existing lifecycle tests keep their isolation. */ + runtime_parent = cbm_safe_getenv("CBM_TEST_DAEMON_RUNTIME_PARENT", buffer, capacity, NULL); #endif - return cbm_daemon_bootstrap_endpoint_new(runtime_parent); + if (!runtime_parent) { + runtime_parent = cbm_safe_getenv("CBM_RUNTIME_DIR", buffer, capacity, NULL); + } + return runtime_parent; +} + +static cbm_daemon_ipc_endpoint_t *main_daemon_endpoint_new(void) { + char runtime_parent_buffer[MAIN_PATH_CAP]; + return cbm_daemon_bootstrap_endpoint_new( + main_runtime_parent(runtime_parent_buffer, sizeof(runtime_parent_buffer))); } static bool main_local_cli_feedback_enabled(int argc, char **argv) { @@ -1522,7 +1541,7 @@ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade( * one is spawned for this command — with a hint that `daemon start` removes * that per-command cost. Only supervised index workers stay in-process. */ static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json) { - cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_bootstrap_endpoint_new(NULL); + cbm_daemon_ipc_endpoint_t *endpoint = main_daemon_endpoint_new(); char executable_path[MAIN_PATH_CAP] = {0}; cbm_daemon_build_identity_t identity; bool prepared = @@ -2435,7 +2454,7 @@ int main(int argc, char **argv) { (void)fputs("Preparing one-shot local CBM command...\n", feedback); (void)fflush(feedback); } - cbm_daemon_ipc_endpoint_t *local_endpoint = cbm_daemon_bootstrap_endpoint_new(NULL); + cbm_daemon_ipc_endpoint_t *local_endpoint = main_daemon_endpoint_new(); char local_executable[MAIN_PATH_CAP]; cbm_daemon_build_identity_t local_identity; cbm_project_lock_manager_t *project_locks = @@ -2595,7 +2614,7 @@ int main(int argc, char **argv) { cbm_index_worker_argv_status_message(worker_status)); return EXIT_FAILURE; } - cbm_daemon_ipc_endpoint_t *worker_endpoint = cbm_daemon_bootstrap_endpoint_new(NULL); + cbm_daemon_ipc_endpoint_t *worker_endpoint = main_daemon_endpoint_new(); cbm_project_lock_manager_t *worker_project_locks = worker_endpoint ? cbm_project_lock_manager_new(worker_endpoint) : NULL; cbm_version_cohort_manager_t *worker_cohort_manager = diff --git a/tests/test_product_runtime_dir_override.py b/tests/test_product_runtime_dir_override.py new file mode 100644 index 000000000..d74f17a0e --- /dev/null +++ b/tests/test_product_runtime_dir_override.py @@ -0,0 +1,154 @@ +r"""Product guard for ``CBM_RUNTIME_DIR``. + +The daemon/CLI rendezvous directory is created under ``%LOCALAPPDATA%`` on +Windows and under the POSIX runtime/home directory otherwise. That ancestry is +not always acceptable to ``cbm_daemon_ipc_private_directory_secure``: a profile +that has acquired a mutation-granting ACE for an untrusted identity fails the +walk, and the binary then cannot start at all -- ``config`` included, because it +needs the same endpoint. Before this guard the only relocation hook was +``CBM_TEST_DAEMON_RUNTIME_PARENT``, compiled out unless ``CBM_ENABLE_TEST_SEAMS`` +is defined, so a test build started while the product build did not. + +``CBM_RUNTIME_DIR`` does not relax the check. The directory it names goes +through exactly the same validation; the operator only chooses an ancestry that +passes. This guard proves three things about a **product** build: + +* the rendezvous is created under the directory the operator named; +* leaving the variable unset does not create anything there; +* a value that fails validation is refused rather than silently ignored. + + python3 tests/test_product_runtime_dir_override.py build/c/codebase-memory-mcp + +Exit code: 0 == green, 1 == behavior regression, 2 == fixture/setup error. +""" + +import os +import subprocess +import sys +import tempfile + +RUNTIME_DIR_ENV = "CBM_RUNTIME_DIR" +CACHE_DIR_ENV = "CBM_CACHE_DIR" +COMMAND_TIMEOUT_SECONDS = 120 + + +def output_text(result): + return ((result.stdout or b"") + (result.stderr or b"")).decode("utf-8", "replace") + + +def run_config_list(binary, work, runtime_dir): + """Run the cheapest command that still needs the coordination endpoint.""" + env = dict(os.environ) + env.pop(RUNTIME_DIR_ENV, None) + # A cache the product creates itself, so it owns that directory's ACL. + env[CACHE_DIR_ENV] = os.path.join(work, "cache") + if runtime_dir is not None: + env[RUNTIME_DIR_ENV] = runtime_dir + try: + return subprocess.run( + [binary, "config", "list"], + capture_output=True, + timeout=COMMAND_TIMEOUT_SECONDS, + env=env, + ) + except (OSError, subprocess.TimeoutExpired) as error: + print("SETUP FAIL: could not run the fixture: %s" % error) + return None + + +def entries_under(path): + try: + return sorted(os.listdir(path)) + except OSError: + return [] + + +def assert_override_relocates_rendezvous(binary, work): + """The rendezvous lands under the named directory, not the default one.""" + runtime_root = os.path.join(work, "runtime-root") + os.makedirs(runtime_root, mode=0o700, exist_ok=True) + + result = run_config_list(binary, work, runtime_root) + if result is None: + return False + text = output_text(result) + if result.returncode != 0: + print("REGRESSION: config list failed with %s set\n%s" % (RUNTIME_DIR_ENV, text)) + return False + + created = entries_under(runtime_root) + if not created: + print( + "REGRESSION: %s=%s was accepted but nothing was created there; the " + "rendezvous still went to the default location.\n%s" + % (RUNTIME_DIR_ENV, runtime_root, text) + ) + return False + print(" ok: rendezvous created under the named directory: %s" % created) + return True + + +def assert_unset_creates_nothing_there(binary, work): + """Without the variable, the named directory stays untouched.""" + untouched = os.path.join(work, "untouched-root") + os.makedirs(untouched, mode=0o700, exist_ok=True) + + result = run_config_list(binary, work, None) + if result is None: + return False + + created = entries_under(untouched) + if created: + print( + "REGRESSION: %s was unset yet %s gained %s" + % (RUNTIME_DIR_ENV, untouched, created) + ) + return False + print(" ok: unset override leaves the directory untouched") + return True + + +def assert_invalid_value_is_refused(binary, work): + """A path that cannot be a private runtime parent must not be ignored.""" + missing = os.path.join(work, "does-not-exist", "nested") + + result = run_config_list(binary, work, missing) + if result is None: + return False + text = output_text(result) + + if result.returncode == 0 and "Configuration:" in text: + print( + "REGRESSION: %s=%s is not a usable runtime parent, but the command " + "succeeded -- the value was silently ignored instead of refused.\n%s" + % (RUNTIME_DIR_ENV, missing, text) + ) + return False + print(" ok: an unusable value is refused rather than silently ignored") + return True + + +def main(): + if len(sys.argv) != 2: + print("usage: %s " % os.path.basename(sys.argv[0])) + return 2 + binary = sys.argv[1] + if not os.path.isfile(binary): + print("SETUP FAIL: binary not found: %s" % binary) + return 2 + + # Keep the endpoint path short: macOS sockaddr_un is capped at 104 bytes. + short_temp_root = "/private/tmp" if sys.platform == "darwin" else tempfile.gettempdir() + with tempfile.TemporaryDirectory(prefix="cbm_rtdir_", dir=short_temp_root) as work: + if not assert_override_relocates_rendezvous(binary, work): + return 1 + if not assert_unset_creates_nothing_there(binary, work): + return 1 + if not assert_invalid_value_is_refused(binary, work): + return 1 + print("\nGREEN: CBM_RUNTIME_DIR relocates the rendezvous in a product build.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())