Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -6668,10 +6668,17 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
if (project) {
int nodes = cbm_store_count_nodes(store, project);
int edges = cbm_store_count_edges(store, project);
/* A negative count is a failed read (CBM_STORE_ERR), not a small
* project. Reporting it as a count would put a bare -1 in the output,
* and `nodes > 0 ? "ready" : "empty"` would answer "empty" for an
* unreadable table — the false all-clear #2012 is about. Say the read
* failed, and name the table that could not be read. */
const bool counts_unreadable = nodes < 0 || edges < 0;
yyjson_mut_obj_add_str(doc, root, "project", project);
yyjson_mut_obj_add_int(doc, root, "nodes", nodes);
yyjson_mut_obj_add_int(doc, root, "edges", edges);
yyjson_mut_obj_add_str(doc, root, "status", nodes > 0 ? "ready" : "empty");
yyjson_mut_obj_add_int(doc, root, "nodes", counts_unreadable ? 0 : nodes);
yyjson_mut_obj_add_int(doc, root, "edges", counts_unreadable ? 0 : edges);
yyjson_mut_obj_add_str(doc, root, "status",
counts_unreadable ? "error" : (nodes > 0 ? "ready" : "empty"));
cbm_project_t proj_info = {0};
bool have_proj_info = cbm_store_get_project(store, project, &proj_info) == CBM_STORE_OK;
if (have_proj_info) {
Expand All @@ -6688,7 +6695,21 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
safe_str_free(&proj_info.name);
safe_str_free(&proj_info.indexed_at);
safe_str_free(&proj_info.root_path);
if (nodes == 0) {
if (counts_unreadable) {
const char *hint;
if (nodes < 0 && edges < 0) {
hint = "The nodes and edges tables could not be read; the database may be "
"corrupt. Re-run index_repository(repo_path=...) or remove the project "
"cache and re-index.";
} else if (nodes < 0) {
hint = "The nodes table could not be read; the database may be corrupt. Re-run "
"index_repository(repo_path=...) or remove the project cache and re-index.";
} else {
hint = "The edges table could not be read; the database may be corrupt. Re-run "
"index_repository(repo_path=...) or remove the project cache and re-index.";
}
yyjson_mut_obj_add_str(doc, root, "hint", hint);
} else if (nodes == 0) {
yyjson_mut_obj_add_str(
doc, root, "hint",
"Project is empty. Re-run index_repository(repo_path=...) to populate.");
Expand Down
11 changes: 9 additions & 2 deletions src/store/store.c
Original file line number Diff line number Diff line change
Expand Up @@ -3069,7 +3069,11 @@ int cbm_store_count_nodes(cbm_store_t *s, const char *project) {
}

bind_text(stmt, SKIP_ONE, project);
int count = 0;
/* A step that does not yield a row is a failed read (SQLITE_CORRUPT,
* SQLITE_BUSY, SQLITE_IOERR), not a count of zero. Report it through the
* error channel this function already uses for a failed prepare, so a
* caller cannot mistake an unreadable table for an empty project. */
int count = CBM_STORE_ERR;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
Expand Down Expand Up @@ -3389,7 +3393,10 @@ int cbm_store_count_edges(cbm_store_t *s, const char *project) {
}

bind_text(stmt, SKIP_ONE, project);
int count = 0;
/* See cbm_store_count_nodes: a non-row step is a failed read, not zero.
* index_status reads this alongside the node count and already treats a
* negative value as degraded. */
int count = CBM_STORE_ERR;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
Expand Down
58 changes: 58 additions & 0 deletions tests/test_mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -3384,6 +3384,63 @@ TEST(tool_index_status_keeps_authoritative_ignored_total_when_rows_are_sampled)
PASS();
}

/* #2012: a COUNT(*) that cannot be read must not be reported as an empty
* project. The store now returns CBM_STORE_ERR for a failed step, and
* index_status has to say the read failed rather than answering "empty" with
* a bare -1 as the count. Dropping the tables after the first call makes the
* step (not the prepare) fail deterministically, because the statements are
* cached by then. */
TEST(tool_index_status_reports_an_unreadable_count_as_an_error) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);

/* First call: a healthy project, and it caches the count statements. */
char *response = cbm_mcp_handle_tool(srv, "index_status",
"{\"project\":\"test-project\",\"format\":\"json\"}");
char *inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0);
ASSERT_NOT_NULL(doc);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "status")), "ready");
yyjson_doc_free(doc);
free(inner);
free(response);

ASSERT_EQ(cbm_store_exec(store, "DROP TABLE nodes;"), 0);
ASSERT_EQ(cbm_store_exec(store, "DROP TABLE edges;"), 0);

response = cbm_mcp_handle_tool(srv, "index_status",
"{\"project\":\"test-project\",\"format\":\"json\"}");
inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
doc = yyjson_read(inner, strlen(inner), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);

/* Not "empty": that is the false all-clear the issue is about. */
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root, "status")), "error");

/* A failed read is not a count, so no negative number reaches the caller. */
ASSERT_TRUE(yyjson_get_int(yyjson_obj_get(root, "nodes")) >= 0);
ASSERT_TRUE(yyjson_get_int(yyjson_obj_get(root, "edges")) >= 0);

/* The hint names the unreadable table instead of telling the user to
* re-index an "empty" project. */
const char *hint = yyjson_get_str(yyjson_obj_get(root, "hint"));
ASSERT_NOT_NULL(hint);
ASSERT_TRUE(strstr(hint, "could not be read") != NULL);
ASSERT_TRUE(strstr(hint, "Project is empty") == NULL);

yyjson_doc_free(doc);
free(inner);
free(response);
cbm_mcp_server_free(srv);
PASS();
}

TEST(tool_output_byte_budgets) {
/* GUARD: absolute byte ceilings on default tool outputs. Re-bloat (e.g.
* a property blob sneaking back into row emission — the fp field alone
Expand Down Expand Up @@ -19827,6 +19884,7 @@ SUITE(mcp) {
RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields);
RUN_TEST(tool_lean_defaults_schema_and_status);
RUN_TEST(tool_index_status_keeps_authoritative_ignored_total_when_rows_are_sampled);
RUN_TEST(tool_index_status_reports_an_unreadable_count_as_an_error);
RUN_TEST(tool_output_regression_gate);
RUN_TEST(tool_output_byte_budgets);
RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552);
Expand Down
32 changes: 32 additions & 0 deletions tests/test_store_nodes.c
Original file line number Diff line number Diff line change
Expand Up @@ -2030,6 +2030,37 @@ TEST(store_count_nodes_unknown_project) {
PASS();
}

/* A COUNT(*) that cannot be read must not be reported as a count of zero:
* index_status renders that as the positive assertion status "empty", so a
* corrupt project looks like one that was never indexed. Dropping the table
* after the statement is cached makes the step fail deterministically. */
TEST(store_count_failed_read_is_not_zero) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");

cbm_node_t n = {.project = "test",
.label = "File",
.name = "main.c",
.qualified_name = "test.main.c",
.file_path = "main.c"};
cbm_store_upsert_node(s, &n);

/* Sanity: a readable table still counts normally. */
ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1);
ASSERT_TRUE(cbm_store_count_edges(s, "test") >= 0);

/* Make the read fail. The statements are cached by the calls above, so the
* step (not the prepare) is what fails once the tables are gone. */
ASSERT_EQ(cbm_store_exec(s, "DROP TABLE nodes;"), 0);
ASSERT_EQ(cbm_store_exec(s, "DROP TABLE edges;"), 0);

ASSERT_TRUE(cbm_store_count_nodes(s, "test") < 0);
ASSERT_TRUE(cbm_store_count_edges(s, "test") < 0);

cbm_store_close(s);
PASS();
}

/* ── Index coverage (#963) ──────────────────────────────────────── */

/* Round-trip + deleted-file prune + shadow miss-graph materialization +
Expand Down Expand Up @@ -2432,4 +2463,5 @@ SUITE(store_nodes) {
RUN_TEST(store_node_properties_special_chars);
RUN_TEST(store_delete_nodes_nonexistent);
RUN_TEST(store_count_nodes_unknown_project);
RUN_TEST(store_count_failed_read_is_not_zero);
}
Loading