Merge pull request #1561 from tmonestudio/codex/bt-240-truthful-index-freshness

feat(mcp): report truthful index freshness from checkout evidence
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-11-03 03:04:02 +01:00
commit 5689ea54ce
12 files changed
+1424 -10

No files matched your search

+219
View File
@@ -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));
}
+36
View File
@@ -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
+163 -3
View File
@@ -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,
+5
View File
@@ -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,
+2
View File
@@ -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
+2
View File
@@ -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. */
+96 -3
View File
@@ -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) {
+5
View File
@@ -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
+317 -1
View File
@@ -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);
}
+469 -3
View File
@@ -18,6 +18,7 @@
#include "test_framework.h"
#include "test_helpers.h"
#include <cli/cli.h>
#include <git/git_context.h>
#include <mcp/index_supervisor.h> /* spawn-count hook — #845 in-process guard */
#include <mcp/mcp.h>
#include <mcp/mcp_internal.h>
@@ -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);
+40
View File
@@ -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);
}
+70
View File
@@ -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);