From 99c3bb4cbe4c0f9542bf722d29cbb5dd01397455 Mon Sep 17 00:00:00 2001 From: kavish-19 <63698788+kavish-19@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:54:51 +0530 Subject: [PATCH 1/3] fix(store): report a failed COUNT read instead of returning zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cbm_store_count_nodes and cbm_store_count_edges treat every sqlite3_step result other than SQLITE_ROW as a count of zero. A read that failed — SQLITE_CORRUPT, SQLITE_BUSY, SQLITE_IOERR — is therefore indistinguishable from a project that genuinely holds no rows, and index_status renders it as the positive assertion status "empty". A user or agent reading that concludes the repository was never indexed and starts a multi-minute re-index, while the corruption itself is never surfaced. Both functions already have an error channel: each returns CBM_STORE_ERR when prepare_cached fails. Only the step result was not reported through it. index_status is already written for that value — it sets degraded on a negative node count and clamps a negative edge count — so the guard existed and simply never fired. Initialise count to CBM_STORE_ERR so a non-row step is reported as a failed read. A successful step still overwrites it with the real count, so the healthy path is unchanged. Closes #2012 Signed-off-by: kavish-19 <63698788+kavish-19@users.noreply.github.com> --- src/store/store.c | 11 +++++++++-- tests/test_store_nodes.c | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/store/store.c b/src/store/store.c index c36f00b44..d2369c1f9 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -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); } @@ -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); } diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index cf7b8bc8f..c08beadad 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -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 + @@ -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); } From 73c509f4246b0f97ca772b6d9a3159181a7fa6b6 Mon Sep 17 00:00:00 2001 From: kavish-19 <63698788+kavish-19@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:54:51 +0530 Subject: [PATCH 2/3] fix(mcp): report an unreadable count as an error, not an empty project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store change alone does not close #2012. handle_index_status reads the counts directly and renders status as `nodes > 0 ? "ready" : "empty"`, so a negative count from a failed read still answered "empty" — now with a bare -1 printed as the count, and with no hint at all, because the "Project is empty" branch is guarded on `nodes == 0`. The guard cited earlier lives in build_index_success_response, which is index_repository's response builder, not this path. Treat a negative node or edge count as a failed read: report status "error", suppress the negative numbers rather than emitting them as counts, and replace the re-index hint with one that names the table that could not be read. Closes #2012 Signed-off-by: kavish-19 <63698788+kavish-19@users.noreply.github.com> --- src/mcp/mcp.c | 28 +++++++++++++++++++---- tests/test_mcp.c | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 3259221c4..959a07684 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -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) { @@ -6688,7 +6695,20 @@ 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) { + yyjson_mut_obj_add_str( + doc, root, "hint", + nodes < 0 && edges < 0 + ? "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." + : (nodes < 0 ? "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." + : "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.")); + } else if (nodes == 0) { yyjson_mut_obj_add_str( doc, root, "hint", "Project is empty. Re-run index_repository(repo_path=...) to populate."); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index f97eda09e..905213cfc 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -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 @@ -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); From 60449f895dbaedb4e74c4f155a8e214d78486e12 Mon Sep 17 00:00:00 2001 From: kavish-19 <63698788+kavish-19@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:00:57 +0530 Subject: [PATCH 3/3] style(mcp): pick the unreadable-count hint with an if/else chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cppcheck 2.20 flags the nested ternary in handle_index_status: src/mcp/mcp.c:6705:30: style: Condition 'nodes<0' is always false [knownConditionTrueFalse] After the `nodes < 0 && edges < 0` arm, cppcheck's value flow takes `nodes < 0` to be the false half of that conjunction and rejects the second test. Select the hint with a plain if / else-if / else chain into a `const char *hint` and add it once, rather than suppressing the check. The three messages are byte-identical to before — only the line wrapping of the adjacent literals moved — so status, counts and hint text are unchanged and the tests added in the previous commit still describe the behaviour. Verified with cppcheck 2.21 locally using the flags from Makefile.cbm's lint-cppcheck target: the finding is reproduced on the parent commit and gone here, with no new ones. clang-format reports no drift over the changed lines. Signed-off-by: kavish-19 <63698788+kavish-19@users.noreply.github.com> --- src/mcp/mcp.c | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 959a07684..005e25b2c 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -6696,18 +6696,19 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { safe_str_free(&proj_info.indexed_at); safe_str_free(&proj_info.root_path); if (counts_unreadable) { - yyjson_mut_obj_add_str( - doc, root, "hint", - nodes < 0 && edges < 0 - ? "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." - : (nodes < 0 ? "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." - : "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.")); + 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",