diff --git a/src/git/git_context.c b/src/git/git_context.c index 9b0b79b22..2ed8d2ec5 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -3,6 +3,7 @@ #include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/log.h" +#include "foundation/mem_core.h" #include "foundation/platform.h" #include "foundation/str_util.h" @@ -17,6 +18,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 = ""; @@ -641,3 +653,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 *)cbm_alloc(CBM_MEM_CLASS_OTHER, path_len + 1); + if (!copy) { + *truncated = true; + return; + } + memcpy(copy, path, path_len); + copy[path_len] = '\0'; + char **grown = (char **)cbm_realloc(CBM_MEM_CLASS_OTHER, *samples, + (size_t)(*sample_count + 1) * sizeof(char *)); + if (!grown) { + cbm_free(CBM_MEM_CLASS_OTHER, 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; + } + + /* An inherited GIT_DIR/GIT_WORK_TREE (a git hook, an editor) overrides + * `git -C`, which would report another repository's worktree here. */ + FILE *fp = cbm_popen_git(cmd); + 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++) { + cbm_free(CBM_MEM_CLASS_OTHER, st->tracked_paths[i]); + } + cbm_free(CBM_MEM_CLASS_OTHER, st->tracked_paths); + for (int i = 0; i < st->untracked_sample_count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, st->untracked_paths[i]); + } + cbm_free(CBM_MEM_CLASS_OTHER, st->untracked_paths); + memset(st, 0, sizeof(*st)); +} diff --git a/src/git/git_context.h b/src/git/git_context.h index 0f3c40378..506643cdb 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -50,4 +50,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 c82f90f11..fbfa463dc 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -49,6 +49,8 @@ enum { * promise. The actual cross-platform contract is this deterministic UTF-8 * byte ceiling, applied only at whole semantic-unit boundaries. */ MCP_OUTPUT_BYTES_PER_TOKEN_ESTIMATE = 4, + 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 @@ -7792,13 +7794,169 @@ static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yy yyjson_mut_obj_add_val(doc, root, "watch", watch); } +/* 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, 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, 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", ""); + } + + 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; + + 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 { + 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"); + } + + /* 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; + if (!sha_present) { + verdict = "unknown"; + } else if (has_stale) { + verdict = "stale"; + } else if (!live_present) { + verdict = "unknown"; + } else if (!status_available) { + verdict = "unknown"; + } else if (has_untracked) { + verdict = "unknown"; + } else { + verdict = "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_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); +} + 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"); char *diagnostics = cbm_mcp_get_string_arg(args, "diagnostics"); int coverage_samples = 0; @@ -7843,6 +8001,8 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { proj_info.indexed_at ? proj_info.indexed_at : ""); if (verbose) { add_git_context_json(doc, root, proj_info.root_path); + add_index_freshness_json(doc, root, store, project, proj_info.root_path, + proj_info.indexed_at); } } add_coverage_report(doc, root, store, project, have_proj_info ? proj_info.indexed_at : NULL, diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 64bc57dba..e492c2b8d 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1631,6 +1631,10 @@ cbm_index_resource_violation_t *cbm_pipeline_resource_violation(cbm_pipeline_t * return p ? &p->resource_violation : 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; } @@ -4165,6 +4169,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas .hash_records_complete = true, .unresolved_calls_complete = pipeline_unresolved_capture_complete(p, coverage_rows_available), + .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 ac2c253af..4e96e732a 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -3858,6 +3858,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .hash_records_complete = true, .unresolved_calls_complete = incremental_capture_complete( coverage_rows_available, unresolved_complete, retained_unresolved_complete), + .indexed_checkout_sha = cbm_pipeline_indexed_checkout_sha(p), }, .surface_rows = NULL, .surface_row_count = 0, @@ -4543,6 +4544,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .hash_records_complete = true, .unresolved_calls_complete = incremental_capture_complete( coverage_rows_available, unresolved_complete, retained_unresolved_complete), + .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 9ef2734b3..ca0f0ec35 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1883,6 +1883,8 @@ const cbm_test_declarations_t *cbm_pipeline_test_declarations(const cbm_pipeline bool cbm_pipeline_project_source_matches(const cbm_pipeline_t *p, const cbm_userconfig_t *fresh); const cbm_index_resource_policy_t *cbm_pipeline_resource_policy(const cbm_pipeline_t *p); cbm_index_resource_violation_t *cbm_pipeline_resource_violation(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/src/store/store.c b/src/store/store.c index ed06236a9..c76fd3625 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -340,6 +340,26 @@ bool cbm_store_edges_schema_current(cbm_store_t *s) { return true; } +/* Best-effort probe for the BT-240 index_checkout table. Writable opens run + * init_schema (which creates 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 index_checkout_table_exists(cbm_store_t *s) { + if (!s || !s->db) { + return false; + } + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name = 'index_checkout';", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + return false; + } + bool has = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return has; +} + static int init_schema(cbm_store_t *s) { const char *ddl = "CREATE TABLE IF NOT EXISTS projects (" @@ -495,6 +515,14 @@ static int init_schema(cbm_store_t *s) { " coverage_version INTEGER NOT NULL DEFAULT 1," " hash_records_complete INTEGER NOT NULL DEFAULT 0," " unresolved_calls_complete INTEGER NOT NULL DEFAULT 0" + ");" + /* BT-240: the checkout an index was built from. A table of its own, + * not an index_coverage_meta column: it is source identity, which the + * pinned graph-digest tables deliberately do not bind, and a new + * table needs no migration of existing databases. */ + "CREATE TABLE IF NOT EXISTS index_checkout (" + " project TEXT PRIMARY KEY REFERENCES projects(name) ON DELETE CASCADE," + " indexed_checkout_sha TEXT NOT NULL" ");"; int rc = exec_sql(s, ddl); @@ -3090,6 +3118,7 @@ int cbm_store_delete_project(cbm_store_t *s, const char *name) { static const char *cleanup_sql[] = { "DELETE FROM index_coverage WHERE project = ?1;", "DELETE FROM index_coverage_meta WHERE project = ?1;", + "DELETE FROM index_checkout WHERE project = ?1;", "DELETE FROM projects WHERE name = ?1 || '::missed';", }; /* doc_link_unresolved exists only in databases published by a build that @@ -4838,6 +4867,34 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) { return CBM_STORE_OK; } +/* Record (or, with no sha, clear) the checkout a project's index was built + * from. Runs inside the caller's coverage transaction, so a failed replace + * keeps the previous identity. */ +static int index_checkout_write(cbm_store_t *s, const char *project, const char *sha) { + bool record = sha && sha[0]; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + record ? "INSERT INTO index_checkout (project, indexed_checkout_sha) " + "VALUES (?1, ?2) ON CONFLICT(project) DO UPDATE SET " + "indexed_checkout_sha=?2;" + : "DELETE FROM index_checkout WHERE project = ?1;", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "index checkout write prepare"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + if (record) { + bind_text(stmt, ST_COL_2, sha); + } + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + store_set_error_sqlite(s, "index checkout write"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + /* Materialize once at publication, after replacement and deleted-file pruning. * json_each sees a safe marker array for malformed/legacy non-array payloads. * The side table cascades with its authoritative coverage rows. */ @@ -4893,7 +4950,7 @@ static int coverage_write_metadata(cbm_store_t *s, const char *project, store_set_error_sqlite(s, "coverage meta delete"); return CBM_STORE_ERR; } - return CBM_STORE_OK; + return index_checkout_write(s, project, NULL); } char recorded_at[CBM_SZ_64]; if (meta->recorded_at && meta->recorded_at[0]) { @@ -4943,7 +5000,7 @@ static int coverage_write_metadata(cbm_store_t *s, const char *project, store_set_error_sqlite(s, "coverage meta upsert"); return CBM_STORE_ERR; } - return CBM_STORE_OK; + return index_checkout_write(s, project, meta->indexed_checkout_sha); } int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, @@ -5218,9 +5275,45 @@ 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); + cbm_free(CBM_MEM_CLASS_STORE, (char *)meta->indexed_checkout_sha); memset(meta, 0, sizeof(*meta)); } +/* Fill out->indexed_checkout_sha from index_checkout. A legacy read-only DB + * (query opens skip init_schema) may lack the table: no identity, not an + * error. On failure out is cleared. */ +static int index_checkout_read(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out) { + if (!index_checkout_table_exists(s)) { + return CBM_STORE_OK; + } + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT indexed_checkout_sha FROM index_checkout WHERE project = ?1;", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "index checkout read prepare"); + cbm_store_coverage_meta_clear(out); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + int rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + const char *sha = (const char *)sqlite3_column_text(stmt, 0); + if (sha && sha[0]) { + out->indexed_checkout_sha = cbm_mem_strdup(CBM_MEM_CLASS_STORE, sha); + rc = out->indexed_checkout_sha ? SQLITE_DONE : SQLITE_NOMEM; + } else { + rc = SQLITE_DONE; + } + } + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + store_set_error_sqlite(s, "index checkout read"); + cbm_store_coverage_meta_clear(out); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out) { if (!out) { return CBM_STORE_ERR; @@ -5267,7 +5360,7 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag cbm_store_coverage_meta_clear(out); return CBM_STORE_ERR; } - return CBM_STORE_OK; + return index_checkout_read(s, project, out); } sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { diff --git a/src/store/store.h b/src/store/store.h index eed6f746d..c3dc5613c 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -805,6 +805,11 @@ typedef struct { int coverage_version; bool hash_records_complete; bool unresolved_calls_complete; + /* Git checkout identity frozen at the successful staged-generation + * boundary (BT-240), stored in the index_checkout table. NULL/"" when the + * run had no git context (non-git repo) or the DB predates that table. + * 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_git_context.c b/tests/test_git_context.c index 5075e76e6..60bc572ca 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -10,7 +10,9 @@ * * The canonical_root tests shell out to `git`, so they SKIP_PLATFORM on * Windows (the CI shell there cannot init a repo via system()). The root-path - * existence test runs on every platform. + * existence test runs on every platform. 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 @@ -646,6 +648,312 @@ TEST(git_branch_resolve_matches_full_context) { #endif /* _WIN32 */ } +/* The freshness verdict reads this snapshot, so an inherited GIT_DIR / + * GIT_WORK_TREE pointing at a dirty decoy must not make a clean fixture + * report the decoy's changes as its own (#2003). */ +TEST(worktree_status_ignores_inherited_repo_env) { +#ifdef _WIN32 + SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI"); +#else + char root[256]; + char *raw = th_mktempdir("cbm_gitenv_status"); + if (!raw) + FAIL("th_mktempdir returned NULL"); + snprintf(root, sizeof(root), "%s", raw); + if (make_env_isolation_tree(root) != 0) { + th_rmtree(root); + SKIP_PLATFORM("git not available to init a repo"); + } + char decoy[512], fixture[512], path[600]; + snprintf(decoy, sizeof(decoy), "%s/decoy", root); + snprintf(fixture, sizeof(fixture), "%s/fixture", root); + /* Dirty only the decoy: one tracked edit and one untracked file. */ + snprintf(path, sizeof(path), "%s/a.go", decoy); + th_write_file(path, "package a // dirty\n"); + snprintf(path, sizeof(path), "%s/untracked.go", decoy); + th_write_file(path, "package a\n"); + + git_env_snapshot_t snap; + git_env_hook_enter(&snap, decoy); + cbm_worktree_status_t st; + int rc = cbm_git_worktree_status(fixture, 4, &st); + git_env_hook_leave(&snap); + bool available = st.available; + int tracked = st.tracked_count; + int untracked = st.untracked_count; + cbm_git_worktree_status_free(&st); + th_rmtree(root); + + ASSERT_EQ(rc, 0); + ASSERT_TRUE(available); + ASSERT_EQ(tracked, 0); /* the fixture is clean; the decoy is not */ + ASSERT_EQ(untracked, 0); + PASS(); +#endif /* _WIN32 */ +} + +/* ── Bounded worktree status (BT-240 increment 3) ────────────────────── + * These run on EVERY platform, not just POSIX: they shell out through + * cbm_popen 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 -q -m 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 -q -m 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) { @@ -658,4 +966,12 @@ SUITE(git_context) { RUN_TEST(is_linked_worktree_false_for_submodule_and_nongit); RUN_TEST(git_context_ignores_inherited_repo_env); RUN_TEST(githistory_ignores_inherited_repo_env); + RUN_TEST(worktree_status_ignores_inherited_repo_env); + 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 91ac812a9..87fa2ba92 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -18,6 +18,7 @@ #include "test_framework.h" #include "test_helpers.h" #include +#include #include /* spawn-count hook — #845 in-process guard */ #include #include @@ -7890,9 +7891,465 @@ TEST(tool_index_status_includes_git_metadata) { PASS(); } -/* ══════════════════════════════════════════════════════════════════ - * TOOL HANDLERS WITH DATA - * ══════════════════════════════════════════════════════════════════ */ +/* 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,\"format\":\"json\"}"); + 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\",\"format\":\"json\"}"); + 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")); + /* 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); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── BT-240: freshness verdict against the recorded indexed checkout ── + * 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. */ + +static int mcp_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 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; +} + +/* Helper: build a server whose project root is the given git repo and whose + * coverage metadata records indexed_checkout_sha = sha (may be NULL). */ +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; +} + +TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches) { + 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,\"format\":\"json\"}"); + 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\"")); + 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); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches) { + 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,\"format\":\"json\"}"); + 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(); +} + +/* 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 -q -m 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,\"format\":\"json\"}"); + 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,\"format\":\"json\"}"); + 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 -q -m 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,\"format\":\"json\"}"); + 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,\"format\":\"json\"}"); + 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,\"format\":\"json\"}"); + 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")); + static const char EXPECTED_PATHS[] = + "\"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, EXPECTED_PATHS)); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} /* #2816: stored recursion must remain visible even when the CALLS graph has * no self-edges. Removing metadata emission must fail these assertions. */ @@ -27236,6 +27693,15 @@ SUITE(mcp) { RUN_TEST(tool_responses_carry_store_generation_issue2585); 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); + 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_path_recursion_metadata_issue2816); diff --git a/tests/test_store_graph_digest.c b/tests/test_store_graph_digest.c index 64617a96e..1065e1339 100644 --- a/tests/test_store_graph_digest.c +++ b/tests/test_store_graph_digest.c @@ -683,6 +683,45 @@ TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates) { cbm_store_close(a);cbm_store_close(b);PASS(); } +/* BT-240 (#1561): the indexed checkout is source identity, which the digest + * never binds, so it lives outside the digested tables. A store written through + * the real coverage API, with and without a checkout SHA, keeps passing the + * live-schema check (test-impact falls back to running everything otherwise), + * and recording or changing the SHA leaves the digest unchanged. */ +TEST(store_graph_digest_indexed_checkout_identity_is_not_bound) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "p", "/live"), CBM_STORE_OK); + cbm_coverage_meta_t meta = {.generation = "g", + .index_mode = "full", + .recorded_at = "t", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true}; + cbm_store_graph_digest_t none = {0}, first = {0}, second = {0}; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK); + bool none_ok = gd_p(s, &none); + meta.indexed_checkout_sha = "0123456789abcdef0123456789abcdef01234567"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK); + bool first_ok = gd_p(s, &first); + meta.indexed_checkout_sha = "89abcdef0123456789abcdef0123456789abcdef"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK); + bool second_ok = gd_p(s, &second); + cbm_coverage_meta_t got = {0}; + int got_rc = cbm_store_coverage_meta_get(s, "p", &got); + bool sha_kept = got_rc == CBM_STORE_OK && got.indexed_checkout_sha && + strcmp(got.indexed_checkout_sha, "89abcdef0123456789abcdef0123456789abcdef") == 0; + cbm_store_coverage_meta_clear(&got); + cbm_store_close(s); + ASSERT_TRUE(none_ok); + ASSERT_TRUE(first_ok); + ASSERT_TRUE(second_ok); + ASSERT_TRUE(sha_kept); + ASSERT_TRUE(gd_equal(&none, &first)); + ASSERT_TRUE(gd_equal(&first, &second)); + PASS(); +} + SUITE(store_graph_digest) { RUN_TEST(store_graph_digest_binds_unresolved_candidate_index); RUN_TEST(store_graph_digest_candidate_index_errors_are_not_legacy_absence); @@ -699,4 +738,5 @@ SUITE(store_graph_digest) { RUN_TEST(store_graph_digest_cancellation_large_blob_never_publishes_prefix); RUN_TEST(store_graph_digest_sql_errors_cleanup_no_mutation_and_close_discard); RUN_TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates); + RUN_TEST(store_graph_digest_indexed_checkout_identity_is_not_bound); } diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index 5d005c00e..88b37882e 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -2706,6 +2706,75 @@ TEST(store_coverage_shadow_excludes_unresolved_calls) { 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"; + 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); + 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_qn_suffix_is_literal); RUN_TEST(store_qn_suffix_is_unbounded); @@ -2716,6 +2785,7 @@ SUITE(store_nodes) { RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments); RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails); RUN_TEST(store_coverage_shadow_excludes_unresolved_calls); + RUN_TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback); RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like); RUN_TEST(sql_relation_labels_match_cbm_label_is_relation); RUN_TEST(store_open_memory);