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/4] 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/4] 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/4] 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/4] 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"); }