From d0e108f09b48554d6c38bce6d2d6a2d9f1843b16 Mon Sep 17 00:00:00 2001 From: imi Date: Mon, 27 Jul 2026 17:16:05 +0000 Subject: [PATCH] fix(index): preserve persisted coverage summaries Signed-off-by: imi --- src/mcp/mcp.c | 54 +++++++++++++++++++++++++++++++++-- tests/test_index_resilience.c | 28 ++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d891e7dd9..f55857ccd 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -6908,6 +6908,54 @@ static void add_parse_partial_summary(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_obj_add_val(doc, root, "parse_partial", pp); } +/* The pipeline persists the complete current coverage set before this + * response is built. Prefer that set over the per-run errors so incremental + * runs that do not revisit a flagged file, and artifact bootstraps, do not + * make existing gaps appear to have vanished. By-design exclusions have + * their own response surface and are not failures. */ +static bool add_persisted_failure_summaries(yyjson_mut_doc *doc, yyjson_mut_val *root, + cbm_store_t *store, const char *project, + const char *logfile) { + cbm_coverage_row_t *rows = NULL; + int row_count = 0; + if (cbm_store_coverage_get(store, project, &rows, &row_count) != CBM_STORE_OK) { + return false; + } + + int failure_count = 0; + for (int i = 0; i < row_count; i++) { + const char *kind = rows[i].kind ? rows[i].kind : ""; + if (strcmp(kind, "not_indexed_dir") != 0 && strcmp(kind, "not_indexed_file") != 0) { + failure_count++; + } + } + + cbm_file_error_t *failures = + failure_count > 0 ? calloc((size_t)failure_count, sizeof(*failures)) : NULL; + if (failure_count > 0 && !failures) { + cbm_store_free_coverage(rows, row_count); + return false; + } + + int n = 0; + for (int i = 0; i < row_count; i++) { + const char *kind = rows[i].kind ? rows[i].kind : ""; + if (strcmp(kind, "not_indexed_dir") == 0 || strcmp(kind, "not_indexed_file") == 0) { + continue; + } + failures[n].path = (char *)rows[i].rel_path; + failures[n].reason = (char *)rows[i].detail; + failures[n].phase = (char *)rows[i].kind; + n++; + } + + add_skipped_summary(doc, root, failures, failure_count, logfile); + add_parse_partial_summary(doc, root, failures, failure_count); + free(failures); + cbm_store_free_coverage(rows, row_count); + return true; +} + /* Write the FULL (uncapped) skip list to a per-run logfile — ONLY when >=1 file * was skipped (no logfile on a clean run). Location: * $CBM_INDEX_LOG (override) else /logs/-.log @@ -6967,8 +7015,6 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc * const cbm_file_error_t *file_errors, int file_error_count, const char *logfile) { add_excluded_summary(doc, root, excluded_dirs, excluded_count); - add_skipped_summary(doc, root, file_errors, file_error_count, logfile); - add_parse_partial_summary(doc, root, file_errors, file_error_count); add_not_indexed_files_summary(doc, root, p); int exp_nodes = -1; @@ -6979,6 +7025,10 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc * const int min_floor = CBM_DUMP_VERIFY_MIN_FLOOR; cbm_store_t *store = resolve_store(srv, project_name); + if (!store || !add_persisted_failure_summaries(doc, root, store, project_name, logfile)) { + add_skipped_summary(doc, root, file_errors, file_error_count, logfile); + add_parse_partial_summary(doc, root, file_errors, file_error_count); + } int nodes = 0; int edges = 0; bool degraded = false; diff --git a/tests/test_index_resilience.c b/tests/test_index_resilience.c index 8d8a601a6..14909a4e4 100644 --- a/tests/test_index_resilience.c +++ b/tests/test_index_resilience.c @@ -439,6 +439,34 @@ TEST(index_parse_partial_reported) { /* Clean neighbors still extract. */ int funcs = rh_count_label(store, lp.project, "Function"); ASSERT_GTE(funcs, 1); + cbm_store_close(store); + store = NULL; + + /* An incremental run that changes only a clean neighbor has no new parser + * errors, but its response must still describe the persisted current + * coverage state for the untouched partial file. */ + struct timespec delay = {.tv_sec = 0, .tv_nsec = INCR_FIX_SLEEP_NS}; + nanosleep(&delay, NULL); + ri_write_text(lp.tmpdir, "good.py", "def alpha():\n return 2\n"); + char iargs[700]; + snprintf(iargs, sizeof(iargs), "{\"repo_path\":\"%s\"}", lp.tmpdir); + char *iresp = cbm_mcp_handle_tool(lp.srv, "index_repository", iargs); + ASSERT_NOT_NULL(iresp); + yyjson_doc *idoc = yyjson_read(iresp, strlen(iresp), 0); + ASSERT_NOT_NULL(idoc); + yyjson_val *isc = yyjson_obj_get(yyjson_doc_get_root(idoc), "structuredContent"); + ASSERT_NOT_NULL(isc); + ASSERT_GTE(yyjson_get_int(yyjson_obj_get(isc, "parse_partial_count")), 1); + yyjson_val *ipp = yyjson_obj_get(isc, "parse_partial"); + ASSERT_NOT_NULL(ipp); + char *ipp_json = yyjson_val_write(ipp, 0, NULL); + ASSERT_NOT_NULL(ipp_json); + ASSERT_NOT_NULL(strstr(ipp_json, "split.c")); + free(ipp_json); + /* The coverage is persisted state, but the logfile remains per-run. */ + ASSERT_NULL(yyjson_obj_get(isc, "logfile")); + yyjson_doc_free(idoc); + free(iresp); yyjson_doc_free(d); free(resp);