From 80f8bda5670c8ac1956e2a8fac7221c40c12da47 Mon Sep 17 00:00:00 2001 From: Hubert Tarnacki Date: Wed, 9 Sep 2026 22:44:19 +0200 Subject: [PATCH 01/16] fix(discover): retain external source directories in fast mode `external` was listed in FAST_SKIP_DIRS, so fast and moderate discovery silently dropped every directory named `external`. In many codebases that name holds first-party code (external-facing APIs, adapters, integration layers) rather than vendored third-party sources, and the dedicated `vendor` / `third_party` / `3rdparty` entries already cover the vendored case. Remove `external` from the fast skip list so those sources are indexed in every mode. The shortened list is regrouped with category comments in the same style as ALWAYS_SKIP_DIRS; element order is unchanged. Signed-off-by: Hubert Tarnacki --- src/discover/discover.c | 24 +++++++++++++++++------- tests/test_discover.c | 7 +++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/discover/discover.c b/src/discover/discover.c index 5cadaed75..e42efa117 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -52,13 +52,23 @@ static const char *ALWAYS_SKIP_DIRS[] = { ".codebase-memory", ".qdrant_code_embeddings", ".tmp", "vendor", "vendored", NULL}; static const char *FAST_SKIP_DIRS[] = { - "generated", "gen", "auto-generated", "fixtures", "testdata", "test_data", - "__tests__", "__mocks__", "__snapshots__", "__fixtures__", "__test__", "docs", - "doc", "documentation", "examples", "example", "samples", "sample", - "assets", "static", "public", "media", "third_party", "thirdparty", - "3rdparty", "external", "migrations", "seeds", "e2e", "integration", - "locale", "locales", "i18n", "l10n", "scripts", "tools", - "hack", "bin", "build", "out", NULL}; + /* Generated */ + "generated", "gen", "auto-generated", + /* Tests */ + "fixtures", "testdata", "test_data", "__tests__", "__mocks__", "__snapshots__", "__fixtures__", + "__test__", + /* Docs and samples */ + "docs", "doc", "documentation", "examples", "example", "samples", "sample", + /* Assets */ + "assets", "static", "public", "media", + /* Third-party */ + "third_party", "thirdparty", "3rdparty", + /* Data and integration tests */ + "migrations", "seeds", "e2e", "integration", + /* Localization */ + "locale", "locales", "i18n", "l10n", + /* Tooling and build output */ + "scripts", "tools", "hack", "bin", "build", "out", NULL}; /* ── Ignored suffixes ───────────────────────────────── */ diff --git a/tests/test_discover.c b/tests/test_discover.c index 0f3dac7d2..d44e82bf3 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -153,6 +153,12 @@ TEST(skip_fast_e2e) { ASSERT_TRUE(cbm_should_skip_dir("e2e", CBM_MODE_FAST)); PASS(); } +TEST(no_skip_fast_external) { + ASSERT_FALSE(cbm_should_skip_dir("external", CBM_MODE_FAST)); + ASSERT_FALSE(cbm_should_skip_dir("external", CBM_MODE_MODERATE)); + ASSERT_FALSE(cbm_should_skip_dir("shop-external-api", CBM_MODE_FAST)); + PASS(); +} /* ── Suffix filters ────────────────────────────────────────────── */ @@ -1867,6 +1873,7 @@ SUITE(discover) { RUN_TEST(skip_fast_assets); RUN_TEST(skip_fast_3rdparty); RUN_TEST(skip_fast_e2e); + RUN_TEST(no_skip_fast_external); /* Suffix filters */ RUN_TEST(suffix_pyc); From 2c71746fa52f4945f7e2edcbd92e5ded91bff395 Mon Sep 17 00:00:00 2001 From: Pcristin Date: Thu, 24 Sep 2026 14:40:36 +0000 Subject: [PATCH 02/16] fix(pipeline): expose unresolved call coverage Signed-off-by: Pcristin --- src/mcp/mcp.c | 143 +++++++++++++++++++++++++--- src/pipeline/pipeline.c | 127 +++++++++++++++++++++++- src/pipeline/pipeline_incremental.c | 50 +++++++++- src/pipeline/pipeline_internal.h | 7 +- src/store/store.c | 10 ++ src/store/store.h | 11 ++- tests/test_mcp.c | 115 ++++++++++++++++++++-- tests/test_pipeline.c | 93 ++++++++++++++++++ 8 files changed, 524 insertions(+), 32 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 78b8dccf9..f3e700aea 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -64,6 +64,7 @@ enum { #include "watcher/watcher.h" #include "foundation/mem.h" #include "foundation/mem_core.h" +#include "foundation/hash_table.h" #include "foundation/diagnostics.h" #include "foundation/platform.h" #include "foundation/compat.h" @@ -6090,11 +6091,13 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s yyjson_mut_val *pp_files = yyjson_mut_arr(doc); yyjson_mut_val *pu_files = yyjson_mut_arr(doc); + yyjson_mut_val *unresolved_files = yyjson_mut_arr(doc); yyjson_mut_val *sk_files = yyjson_mut_arr(doc); yyjson_mut_val *ni_dirs = yyjson_mut_arr(doc); yyjson_mut_val *ni_files = yyjson_mut_arr(doc); int pp_n = 0; int pu_n = 0; + int unresolved_n = 0; int sk_n = 0; int ni_dir_n = 0; int ni_file_n = 0; @@ -6124,6 +6127,11 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s yyjson_mut_arr_add_val(pu_files, fe); } pu_n++; + } else if (strcmp(kind, "unresolved_calls") == 0) { + if (unresolved_n < sample_limit) { + yyjson_mut_arr_add_strcpy(doc, unresolved_files, rows[i].rel_path); + } + unresolved_n++; } else if (strcmp(kind, "not_indexed_dir") == 0) { if (ni_dir_n < sample_limit) { yyjson_mut_arr_add_strcpy(doc, ni_dirs, rows[i].rel_path); @@ -6181,6 +6189,12 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s yyjson_mut_obj_add_bool(doc, pu, "truncated", pu_n > COVERAGE_FILE_CAP); yyjson_mut_obj_add_val(doc, root, "parse_unusable", pu); + yyjson_mut_val *unresolved = yyjson_mut_obj(doc); + yyjson_mut_obj_add_val(doc, unresolved, "files", unresolved_files); + yyjson_mut_obj_add_int(doc, unresolved, "count", unresolved_n); + yyjson_mut_obj_add_bool(doc, unresolved, "truncated", unresolved_n > sample_limit); + yyjson_mut_obj_add_val(doc, root, "unresolved_calls", unresolved); + yyjson_mut_val *sk = yyjson_mut_obj(doc); yyjson_mut_obj_add_val(doc, sk, "files", sk_files); yyjson_mut_obj_add_int(doc, sk, "count", sk_n); @@ -6218,7 +6232,7 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s } yyjson_mut_obj_add_val(doc, root, "not_indexed", ni); - if (sample_limit > 0 && (pp_n > 0 || sk_n > 0)) { + if (sample_limit > 0 && (pp_n > 0 || sk_n > 0 || unresolved_n > 0)) { yyjson_mut_obj_add_str( doc, root, "coverage_note", "Best-effort signal, not a completeness guarantee: parse_partial files WERE indexed, " @@ -6226,7 +6240,9 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s "(tree-sitter error recovery still salvages some). skipped files were not indexed at " "all. Prefer text search (grep) for flagged files/ranges. Files absent from this list " "are NOT guaranteed to be fully indexed. (not_indexed entries are a separate, " - "BY-DESIGN class — deliberate ignore rules, not failures.)"); + "BY-DESIGN class — deliberate ignore rules, not failures.) " + "Unresolved callsites need source verification because CALLS totals may be " + "incomplete."); } } @@ -6467,6 +6483,9 @@ static const char *coverage_status(const cbm_coverage_row_t *rows, int count, if (pass == 0 && strcmp(kind, "parse_partial") == 0) { return "partial"; } + if (pass == 0 && strcmp(kind, "unresolved_calls") == 0) { + return "unresolved_calls"; + } if (pass == 1 && strncmp(kind, "not_indexed", 11) == 0) { return "excluded"; } @@ -6491,6 +6510,9 @@ static const char *coverage_recommended_action(const char *status, const char *f if (strcmp(status, "partial") == 0) { return "read_ranges_and_verify_scope"; } + if (strcmp(status, "unresolved_calls") == 0) { + return "read_source_and_verify_calls"; + } if (strcmp(status, "unusable") == 0) { /* The ranges cover nearly the whole file, so sending a reader to them * is the same as sending them to the file. Say the useful thing. */ @@ -9350,6 +9372,102 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { bool optional_fields_omitted = false; char *json = NULL; + bool trace_includes_calls = false; + for (int i = 0; i < edge_type_count; i++) { + if (strcmp(edge_types[i], "CALLS") == 0) { + trace_includes_calls = true; + break; + } + } + bool unresolved_out = false; + bool unresolved_in = false; + if (trace_includes_calls) { + cbm_coverage_meta_t coverage_meta = {0}; + int meta_rc = cbm_store_coverage_meta_get(store, project, &coverage_meta); + if (meta_rc == CBM_STORE_OK) { + if (coverage_meta.coverage_version < CBM_UNRESOLVED_CALL_COVERAGE_VERSION || + !coverage_meta.recording_status || + strcmp(coverage_meta.recording_status, "unavailable") == 0) { + unresolved_out = do_outbound; + unresolved_in = do_inbound; + } + } else { + unresolved_out = do_outbound; + unresolved_in = do_inbound; + } + cbm_store_coverage_meta_clear(&coverage_meta); + if ((do_outbound && !unresolved_out) || (do_inbound && !unresolved_in)) { + CBMHashTable *callers = do_outbound ? cbm_ht_create(0) : NULL; + CBMHashTable *leaves = do_inbound ? cbm_ht_create(0) : NULL; + if ((do_outbound && !callers) || (do_inbound && !leaves)) { + unresolved_out = do_outbound; + unresolved_in = do_inbound; + } else { + bool key_set_failed = false; + for (int i = 0; i < node_count; i++) { + if (callers && nodes[i].qualified_name) { + cbm_ht_set(callers, nodes[i].qualified_name, (void *)1); + key_set_failed |= !cbm_ht_has(callers, nodes[i].qualified_name); + } + if (leaves && nodes[i].name) { + cbm_ht_set(leaves, nodes[i].name, (void *)1); + key_set_failed |= !cbm_ht_has(leaves, nodes[i].name); + } + } + for (int i = 0; callers && i < tr_out.visited_count; i++) { + const cbm_node_t *node = &tr_out.visited[i].node; + if ((include_tests || !is_test_file(node->file_path)) && node->qualified_name) { + cbm_ht_set(callers, node->qualified_name, (void *)1); + key_set_failed |= !cbm_ht_has(callers, node->qualified_name); + } + } + for (int i = 0; leaves && i < tr_in.visited_count; i++) { + const cbm_node_t *node = &tr_in.visited[i].node; + if ((include_tests || !is_test_file(node->file_path)) && node->name) { + cbm_ht_set(leaves, node->name, (void *)1); + key_set_failed |= !cbm_ht_has(leaves, node->name); + } + } + cbm_coverage_row_t *rows = NULL; + int row_count = 0; + if (key_set_failed || cbm_store_coverage_get_unresolved_calls( + store, project, &rows, &row_count) != CBM_STORE_OK) { + unresolved_out = do_outbound; + unresolved_in = do_inbound; + } else { + for (int i = 0; i < row_count && ((!unresolved_out && do_outbound) || + (!unresolved_in && do_inbound)); + i++) { + yyjson_doc *detail = yyjson_read(rows[i].detail, strlen(rows[i].detail), 0); + yyjson_val *sites = detail ? yyjson_doc_get_root(detail) : NULL; + if (!yyjson_is_arr(sites)) { + unresolved_out = do_outbound; + unresolved_in = do_inbound; + } else { + size_t idx, max; + yyjson_val *site; + yyjson_arr_foreach(sites, idx, max, site) { + const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller")); + const char *leaf = yyjson_get_str(yyjson_obj_get(site, "leaf")); + if (callers && caller && cbm_ht_has(callers, caller)) + unresolved_out = true; + if (leaves && leaf && cbm_ht_has(leaves, leaf)) + unresolved_in = true; + } + } + if (detail) + yyjson_doc_free(detail); + } + } + cbm_store_free_coverage(rows, row_count); + } + cbm_ht_free(callers); + cbm_ht_free(leaves); + } + } + const char *out_relation = unresolved_out ? "unknown" : (tr_out.truncated ? "gte" : "eq"); + const char *in_relation = unresolved_in ? "unknown" : (tr_in.truncated ? "gte" : "eq"); + render_trace_output:; int rows_left = row_target; out_len = requested_out_len < rows_left ? requested_out_len : rows_left; @@ -9438,7 +9556,7 @@ render_trace_output:; bool flat_trace = render_risk || render_data_flow; if (do_outbound) { cbm_tree_scalar_int(&sb, "callees_total", out_total); - cbm_tree_scalar_str(&sb, "callees_total_relation", tr_out.truncated ? "gte" : "eq"); + cbm_tree_scalar_str(&sb, "callees_total_relation", out_relation); if (flat_trace) { bfs_to_toon_table(&sb, "callees", &view_out, render_risk, include_tests, render_data_flow, render_evidence, &out_edge_ctx); @@ -9449,7 +9567,7 @@ render_trace_output:; } if (do_inbound) { cbm_tree_scalar_int(&sb, "callers_total", in_total); - cbm_tree_scalar_str(&sb, "callers_total_relation", tr_in.truncated ? "gte" : "eq"); + cbm_tree_scalar_str(&sb, "callers_total_relation", in_relation); if (flat_trace) { bfs_to_toon_table(&sb, "callers", &view_in, render_risk, include_tests, render_data_flow, render_evidence, &in_edge_ctx); @@ -9515,8 +9633,7 @@ render_trace_output:; } if (do_outbound) { yyjson_mut_obj_add_int(doc, root, "callees_total", out_total); - yyjson_mut_obj_add_str(doc, root, "callees_total_relation", - tr_out.truncated ? "gte" : "eq"); + yyjson_mut_obj_add_str(doc, root, "callees_total_relation", out_relation); yyjson_mut_obj_add_val( doc, root, "callees", bfs_to_tree_json(doc, &view_out, risk_labels && emit_optional_fields, include_tests, @@ -9525,8 +9642,7 @@ render_trace_output:; } if (do_inbound) { yyjson_mut_obj_add_int(doc, root, "callers_total", in_total); - yyjson_mut_obj_add_str(doc, root, "callers_total_relation", - tr_in.truncated ? "gte" : "eq"); + yyjson_mut_obj_add_str(doc, root, "callers_total_relation", in_relation); yyjson_mut_obj_add_val( doc, root, "callers", bfs_to_tree_json(doc, &view_in, risk_labels && emit_optional_fields, include_tests, @@ -9584,13 +9700,11 @@ render_trace_output:; cbm_sb_init(&floor); if (do_outbound) { cbm_tree_scalar_int(&floor, "callees_total", out_total); - cbm_tree_scalar_str(&floor, "callees_total_relation", - tr_out.truncated ? "gte" : "eq"); + cbm_tree_scalar_str(&floor, "callees_total_relation", out_relation); } if (do_inbound) { cbm_tree_scalar_int(&floor, "callers_total", in_total); - cbm_tree_scalar_str(&floor, "callers_total_relation", - tr_in.truncated ? "gte" : "eq"); + cbm_tree_scalar_str(&floor, "callers_total_relation", in_relation); } cbm_tree_scalar_bool(&floor, "has_more", floor_has_more); if (floor_has_more) { @@ -9615,12 +9729,11 @@ render_trace_output:; if (do_outbound) { yyjson_mut_obj_add_int(floor_doc, floor, "callees_total", out_total); yyjson_mut_obj_add_str(floor_doc, floor, "callees_total_relation", - tr_out.truncated ? "gte" : "eq"); + out_relation); } if (do_inbound) { yyjson_mut_obj_add_int(floor_doc, floor, "callers_total", in_total); - yyjson_mut_obj_add_str(floor_doc, floor, "callers_total_relation", - tr_in.truncated ? "gte" : "eq"); + yyjson_mut_obj_add_str(floor_doc, floor, "callers_total_relation", in_relation); } yyjson_mut_obj_add_bool(floor_doc, floor, "has_more", floor_has_more); if (floor_has_more) { diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index c808cb1b4..9d12c55d9 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -20,6 +20,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6 }; #include "pipeline/artifact.h" #include "pipeline/pipeline_internal.h" #include "pipeline/lsp_surface.h" +#include "pipeline/lsp_resolve.h" #include "pipeline/pass_lsp_cross.h" #include "pipeline/pass_ensemble_routing.h" #include "pipeline/worker_pool.h" @@ -42,6 +43,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6 }; #include "foundation/mem_core.h" #include "result_spill.h" #include "foundation/secure_random.h" +#include "yyjson/yyjson.h" #include #include @@ -260,6 +262,11 @@ struct cbm_pipeline { cbm_lsp_surface_row_t *surface_rows; int surface_row_count; + /* Per-file unresolved invocation diagnostics, published as coverage rows. */ + cbm_coverage_row_t *unresolved_rows; + int unresolved_count; + bool unresolved_capture_failed; + /* Deterministic test-only seam at the final publication boundary. Kept * per pipeline so concurrent test/process activity cannot cross-trigger. */ void (*before_publish_hook)(cbm_pipeline_t *, const char *, void *); @@ -431,6 +438,96 @@ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *row p->surface_row_count = count; } +static void *unresolved_json_alloc(void *ctx, size_t size) { + (void)ctx; + return cbm_alloc(CBM_MEM_CLASS_DUMP, size); +} + +static void *unresolved_json_realloc(void *ctx, void *ptr, size_t old_size, size_t size) { + (void)ctx; + (void)old_size; + return cbm_realloc(CBM_MEM_CLASS_DUMP, ptr, size); +} + +static void unresolved_json_free(void *ctx, void *ptr) { + (void)ctx; + cbm_free(CBM_MEM_CLASS_DUMP, ptr); +} + +void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path, + const CBMFileResult *result) { + if (!p || !rel_path || !result || result->resolved_calls.count == 0 || + p->unresolved_capture_failed) { + return; + } + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + if (!doc) { + p->unresolved_capture_failed = true; + return; + } + yyjson_mut_val *sites = yyjson_mut_arr(doc); + yyjson_mut_doc_set_root(doc, sites); + int count = 0; + for (int i = 0; i < result->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &result->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->strategy || + strcmp(rc->strategy, "lsp_unresolved") != 0 || !rc->caller_qn || !rc->callee_qn) { + continue; + } + const char *leaf = strrchr(rc->callee_qn, '.'); + leaf = leaf ? leaf + 1 : rc->callee_qn; + yyjson_mut_val *site = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, site, "caller", rc->caller_qn); + yyjson_mut_obj_add_strcpy(doc, site, "leaf", leaf); + yyjson_mut_obj_add_uint(doc, site, "start_byte", rc->site_start_byte); + yyjson_mut_obj_add_uint(doc, site, "end_byte", rc->site_end_byte); + yyjson_mut_obj_add_strcpy(doc, site, "reason", rc->reason ? rc->reason : "unresolved"); + yyjson_mut_arr_add_val(sites, site); + count++; + } + if (count > 0) { + yyjson_alc allocator = {.malloc = unresolved_json_alloc, + .realloc = unresolved_json_realloc, + .free = unresolved_json_free}; + char *detail = yyjson_mut_write_opts(doc, 0, &allocator, NULL, NULL); + char *path = cbm_mem_strdup(CBM_MEM_CLASS_DUMP, rel_path); + if (!detail || !path) { + p->unresolved_capture_failed = true; + cbm_free(CBM_MEM_CLASS_DUMP, detail); + cbm_free(CBM_MEM_CLASS_DUMP, path); + } else { + cbm_coverage_row_t *rows = + cbm_realloc(CBM_MEM_CLASS_DUMP, p->unresolved_rows, + (size_t)(p->unresolved_count + 1) * sizeof(*rows)); + if (!rows) { + p->unresolved_capture_failed = true; + cbm_free(CBM_MEM_CLASS_DUMP, detail); + cbm_free(CBM_MEM_CLASS_DUMP, path); + } else { + p->unresolved_rows = rows; + p->unresolved_rows[p->unresolved_count++] = (cbm_coverage_row_t){ + .rel_path = path, .kind = "unresolved_calls", .detail = detail}; + } + } + } + yyjson_mut_doc_free(doc); +} + +void cbm_pipeline_mark_unresolved_capture_failed(cbm_pipeline_t *p) { + if (p) + p->unresolved_capture_failed = true; +} + +void cbm_pipeline_get_unresolved_calls(cbm_pipeline_t *p, cbm_coverage_row_t **rows, int *count, + bool *complete) { + if (rows) + *rows = p ? p->unresolved_rows : NULL; + if (count) + *count = p ? p->unresolved_count : 0; + if (complete) + *complete = p && !p->unresolved_capture_failed; +} + void cbm_pipeline_free(cbm_pipeline_t *p) { if (!p) { return; @@ -461,6 +558,11 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { cbm_store_free_lsp_surfaces(p->surface_rows, p->surface_row_count); p->surface_rows = NULL; p->surface_row_count = 0; + for (int i = 0; i < p->unresolved_count; i++) { + cbm_free(CBM_MEM_CLASS_DUMP, (char *)p->unresolved_rows[i].rel_path); + cbm_free(CBM_MEM_CLASS_DUMP, (char *)p->unresolved_rows[i].detail); + } + cbm_free(CBM_MEM_CLASS_DUMP, p->unresolved_rows); cbm_git_context_free(&p->git_ctx); /* gbuf, store, registry freed during/after run */ /* Defensively free userconfig in case run() was never called or panicked */ @@ -1469,6 +1571,9 @@ static int run_sequential_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, if (seq_cache) { for (int i = 0; i < file_count; i++) { if (seq_cache[i]) { + if (rc == 0) { + cbm_pipeline_record_unresolved_calls(p, files[i].rel_path, seq_cache[i]); + } cbm_free_result(seq_cache[i]); } } @@ -1694,6 +1799,18 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, cbm_clock_gettime(CLOCK_MONOTONIC, t); rc = cbm_parallel_resolve(ctx, files, file_count, cache, &shared_ids, worker_count, all_defs, def_count, def_modules, module_def_index, &cross_registries); + if (rc == 0) { + for (int i = 0; i < file_count; i++) { + bool loaded = false; + CBMFileResult *result = cbm_pipeline_result_acquire(ctx, cache, i, NULL, &loaded); + if (result) { + cbm_pipeline_record_unresolved_calls(p, files[i].rel_path, result); + } else if (ctx->spill && cbm_result_spill_has(ctx->spill, i)) { + cbm_pipeline_mark_unresolved_capture_failed(p); + } + cbm_pipeline_result_release(result, loaded); + } + } cbm_log_info("pass.timing", "pass", "parallel_resolve", "elapsed_ms", itoa_buf((int)elapsed_ms(*t))); pipeline_phase_mark("parallel_resolve"); @@ -2497,14 +2614,18 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas return CBM_PIPELINE_ABORT_PRESERVE_DB; } - int cov_total = p->file_errors_count + p->excluded_count + p->ignored_count; + int cov_total = + p->file_errors_count + p->excluded_count + p->ignored_count + p->unresolved_count; cbm_coverage_row_t *cov = NULL; int cov_count = 0; - bool coverage_rows_available = cov_total == 0; + bool coverage_rows_available = cov_total == 0 && !p->unresolved_capture_failed; if (cov_total > 0) { cov = malloc((size_t)cov_total * sizeof(*cov)); if (cov) { - coverage_rows_available = true; + coverage_rows_available = !p->unresolved_capture_failed; + for (int i = 0; i < p->unresolved_count; i++) { + cov[cov_count++] = p->unresolved_rows[i]; + } for (int i = 0; i < p->file_errors_count; i++) { cov[cov_count++] = (cbm_coverage_row_t){.rel_path = p->file_errors[i].path, .kind = p->file_errors[i].phase, diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 154518658..f0953764e 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -30,6 +30,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24 }; #include "foundation/compat_thread.h" #include "foundation/platform.h" #include "foundation/sha256.h" +#include "result_spill.h" #include #include @@ -1351,6 +1352,19 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed rc = cbm_parallel_resolve(ctx, changed_files, ci, cache, &shared_ids, worker_count, all_defs, all_def_count, closure ? closure->def_modules : NULL, module_def_index, registries_arg); + if (rc == 0) { + for (int i = 0; i < ci; i++) { + bool loaded = false; + CBMFileResult *result = cbm_pipeline_result_acquire(ctx, cache, i, NULL, &loaded); + if (result) { + cbm_pipeline_record_unresolved_calls(ctx->pipeline, changed_files[i].rel_path, + result); + } else if (ctx->spill && cbm_result_spill_has(ctx->spill, i)) { + cbm_pipeline_mark_unresolved_capture_failed(ctx->pipeline); + } + cbm_pipeline_result_release(result, loaded); + } + } if (module_def_index) { cbm_pxc_free_module_def_index(module_def_index); } @@ -1401,6 +1415,14 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed if (rc == 0) { rc = cbm_pipeline_pass_semantic(ctx, changed_files, ci); } + if (rc == 0 && cache) { + for (int i = 0; i < ci; i++) { + if (cache[i]) { + cbm_pipeline_record_unresolved_calls(ctx->pipeline, changed_files[i].rel_path, + cache[i]); + } + } + } if (rc == 0) { rc = cbm_pipeline_check_cancel(ctx); } @@ -2237,11 +2259,17 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char int run_ignored_count = 0; int run_ignored_total = 0; cbm_pipeline_get_ignored(p, &run_ignored, &run_ignored_count, &run_ignored_total); - int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count; - bool coverage_rows_available = cov_cap == 0; + cbm_coverage_row_t *run_unresolved = NULL; + int run_unresolved_count = 0; + bool unresolved_complete = false; + cbm_pipeline_get_unresolved_calls(p, &run_unresolved, &run_unresolved_count, + &unresolved_complete); + int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count + + run_unresolved_count; + bool coverage_rows_available = cov_cap == 0 && unresolved_complete; if (cov_cap > 0) { cov = (cbm_coverage_row_t *)malloc((size_t)cov_cap * sizeof(*cov)); - coverage_rows_available = cov != NULL; + coverage_rows_available = cov != NULL && unresolved_complete; } if (cov) { for (int i = 0; i < old_cov_count; i++) { @@ -2257,6 +2285,9 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char cov[cov_n].detail = run_errs[i].reason; cov_n++; } + for (int i = 0; i < run_unresolved_count; i++) { + cov[cov_n++] = run_unresolved[i]; + } for (int i = 0; i < run_excluded_count; i++) { cov[cov_n].rel_path = run_excluded[i]; cov[cov_n].kind = "not_indexed_dir"; @@ -2810,11 +2841,17 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil cbm_pipeline_get_ignored(p, &run_ignored, &run_ignored_count, &run_ignored_total); cbm_coverage_row_t *cov = NULL; int cov_n = 0; - int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count; + cbm_coverage_row_t *run_unresolved = NULL; + int run_unresolved_count = 0; + bool unresolved_complete = false; + cbm_pipeline_get_unresolved_calls(p, &run_unresolved, &run_unresolved_count, + &unresolved_complete); + int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count + + run_unresolved_count; if (cov_cap > 0) { cov = (cbm_coverage_row_t *)malloc((size_t)cov_cap * sizeof(*cov)); } - bool coverage_rows_available = cov_cap == 0 || cov != NULL; + bool coverage_rows_available = (cov_cap == 0 || cov != NULL) && unresolved_complete; if (cov) { CBMHashTable *changed_set = cbm_ht_create(ci > 0 ? (size_t)ci * PAIR_LEN : CBM_SZ_64); for (int i = 0; i < ci; i++) { @@ -2834,6 +2871,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil cov[cov_n].detail = run_errs[i].reason; cov_n++; } + for (int i = 0; i < run_unresolved_count; i++) { + cov[cov_n++] = run_unresolved[i]; + } for (int i = 0; i < run_excluded_count; i++) { cov[cov_n].rel_path = run_excluded[i]; cov[cov_n].kind = "not_indexed_dir"; diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index e7782adef..182241eca 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -789,7 +789,7 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr /* Compatibility contract persisted in coverage metadata. Increment when a * graph/manifest semantic change makes prior exact-input indexes unsafe. */ -enum { CBM_SEMANTIC_INDEX_VERSION = 3 }; +enum { CBM_SEMANTIC_INDEX_VERSION = CBM_UNRESOLVED_CALL_COVERAGE_VERSION }; typedef struct { cbm_gbuf_t *gbuf; @@ -874,6 +874,11 @@ void cbm_pipeline_discard_stage(const char *stage_path); * Takes ownership; dump_and_persist_hashes writes them into the staging * store and cbm_pipeline_free releases them. Passing NULL/0 clears. */ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *rows, int count); +void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path, + const CBMFileResult *result); +void cbm_pipeline_mark_unresolved_capture_failed(cbm_pipeline_t *p); +void cbm_pipeline_get_unresolved_calls(cbm_pipeline_t *p, cbm_coverage_row_t **rows, int *count, + bool *complete); /* Pipeline accessors for incremental use */ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p); diff --git a/src/store/store.c b/src/store/store.c index 558f974d2..5b1936dba 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -329,6 +329,8 @@ static int init_schema(cbm_store_t *s) { " detail TEXT DEFAULT ''," " PRIMARY KEY (project, rel_path, kind)" ");" + "CREATE INDEX IF NOT EXISTS idx_index_coverage_project_kind " + "ON index_coverage(project, kind);" /* One row per completed coverage persistence attempt. Kept separate * from projects so existing graph/artifact schema stays compatible and * a missing row unambiguously means coverage metadata is unavailable. */ @@ -4215,6 +4217,14 @@ int cbm_store_coverage_get_scope(cbm_store_t *s, const char *project, const char return coverage_query_rows(s, project, scope, sql, out, count); } +int cbm_store_coverage_get_unresolved_calls(cbm_store_t *s, const char *project, + cbm_coverage_row_t **out, int *count) { + static const char sql[] = "SELECT rel_path, kind, detail FROM index_coverage " + "WHERE project = ?1 AND kind = ?2 " + "ORDER BY rel_path;"; + return coverage_query_rows(s, project, "unresolved_calls", sql, out, count); +} + void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) { if (!meta) { return; diff --git a/src/store/store.h b/src/store/store.h index b6ce844f7..82489255e 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -639,7 +639,9 @@ int cbm_store_delete_file_hashes(cbm_store_t *s, const char *project); /* One best-effort coverage row: a file the indexer could not fully cover. * kind "parse_partial" = indexed but the parse tree had ERROR/MISSING regions * (detail = 1-based line ranges "12-40,88-90"); skip kinds "read"/"extract"/ - * "oversized" = not indexed at all (detail = reason). Stored in the separate + * "oversized" = not indexed at all (detail = reason). "unresolved_calls" is + * indexed source whose invocation targets were not resolved (detail = JSON + * array of caller, leaf, source byte span, and reason). Stored in the separate * index_coverage table — coverage is metadata ABOUT the graph, never mixed * into the graph itself. */ typedef struct { @@ -648,6 +650,10 @@ typedef struct { const char *detail; } cbm_coverage_row_t; +/* Older generations have no unresolved-call records, so their exact trace + * totals cannot be trusted after this signal becomes part of coverage. */ +enum { CBM_UNRESOLVED_CALL_COVERAGE_VERSION = 4 }; + /* Metadata describing how completely one index run recorded the best-effort * coverage signal. `recording_status` is "complete", "truncated", or * "unavailable"; it is deliberately separate from hash_records_complete. @@ -690,6 +696,9 @@ int cbm_store_coverage_get_path(cbm_store_t *s, const char *project, const char * ancestor that covers the scope. Prefix matching is segment-boundary safe. */ int cbm_store_coverage_get_scope(cbm_store_t *s, const char *project, const char *scope, cbm_coverage_row_t **out, int *count); +/* Fetch only unresolved invocation diagnostics for trace exactness checks. */ +int cbm_store_coverage_get_unresolved_calls(cbm_store_t *s, const char *project, + cbm_coverage_row_t **out, int *count); /* Fetch/free the metadata paired with the current coverage row set. */ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 60649f505..02cb9db01 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -5971,9 +5971,9 @@ TEST(tool_check_index_coverage_freshness_uses_indexer_mtime_source_issue1714) { ASSERT_EQ(cbm_path_info_utf8(source_path, &info), 0); /* The hash exactly as the indexer writes it: same source, ns precision. */ - ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", info.mtime_ns, - info.size), - CBM_STORE_OK); + ASSERT_EQ( + cbm_store_upsert_file_hash(store, "test-project", "main.go", "", info.mtime_ns, info.size), + CBM_STORE_OK); /* format=json, like every other coverage test here: the DEFAULT response is * the compact table, in which a field name never appears. Keeping the @@ -5990,11 +5990,11 @@ TEST(tool_check_index_coverage_freshness_uses_indexer_mtime_source_issue1714) { /* A hash stored at seconds precision — what a stat-based reader previously * compared against — must NOT match an unchanged file: the comparison must * stay nanosecond-exact, or part of mtime resolution is silently dropped. */ - int64_t seconds_mtime_ns = (info.mtime_ns / (int64_t)CBM_NSEC_PER_SEC) * - (int64_t)CBM_NSEC_PER_SEC; + int64_t seconds_mtime_ns = + (info.mtime_ns / (int64_t)CBM_NSEC_PER_SEC) * (int64_t)CBM_NSEC_PER_SEC; if (seconds_mtime_ns != info.mtime_ns) { - ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", - seconds_mtime_ns, info.size), + ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", seconds_mtime_ns, + info.size), CBM_STORE_OK); response = cbm_mcp_handle_tool(srv, "check_index_coverage", "{\"project\":\"test-project\",\"paths\":[\"main.go\"]," @@ -6638,6 +6638,13 @@ TEST(tool_trace_budget_never_slices_identifiers) { const char *project = "trace-byte-budget"; cbm_mcp_server_set_project(srv, project); ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/trace-byte-budget"), CBM_STORE_OK); + cbm_coverage_meta_t coverage_meta = {.generation = "fixture", + .index_mode = "full", + .recorded_at = "2026-09-24T00:00:00Z", + .recording_status = "complete", + .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .hash_records_complete = true}; + ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, NULL, 0, &coverage_meta), CBM_STORE_OK); cbm_node_t hub = {.project = project, .label = "Function", @@ -6983,6 +6990,13 @@ TEST(tool_trace_reports_engine_saturation_as_lower_bound) { const char *project = "trace-engine-cap"; cbm_mcp_server_set_project(srv, project); ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/trace-engine-cap"), CBM_STORE_OK); + cbm_coverage_meta_t coverage_meta = {.generation = "fixture", + .index_mode = "full", + .recorded_at = "2026-09-24T00:00:00Z", + .recording_status = "complete", + .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .hash_records_complete = true}; + ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, NULL, 0, &coverage_meta), CBM_STORE_OK); cbm_node_t hub = {.project = project, .label = "Function", .name = "hub", @@ -7412,6 +7426,92 @@ TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped) { PASS(); } +TEST(tool_trace_path_marks_unresolved_call_totals_unknown) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "unresolved-trace"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/unresolved-trace"); + cbm_node_t caller = {.project = proj, + .label = "Function", + .name = "run", + .qualified_name = "unresolved-trace.service.run", + .file_path = "service.js", + .start_line = 1, + .end_line = 4}; + cbm_node_t callee = {.project = proj, + .label = "Function", + .name = "buscar", + .qualified_name = "unresolved-trace.client.buscar", + .file_path = "client.js", + .start_line = 1, + .end_line = 2}; + ASSERT_GT(cbm_store_upsert_node(st, &caller), 0); + ASSERT_GT(cbm_store_upsert_node(st, &callee), 0); + ASSERT_EQ(cbm_store_upsert_file_hash(st, proj, "service.js", "fixture", 0, 0), CBM_STORE_OK); + char *out = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":" + "\"unresolved-trace\",\"function_name\":\"run\",\"direction\":\"outbound\"," + "\"format\":\"json\"}}}"); + char *txt = extract_text_content(out); + ASSERT_NOT_NULL(strstr(txt, "\"callees_total_relation\":\"unknown\"")); + free(txt); + free(out); + cbm_coverage_row_t row = { + .rel_path = "service.js", + .kind = "unresolved_calls", + .detail = "[{\"caller\":\"unresolved-trace.service.run\",\"leaf\":\"buscar\"," + "\"start_byte\":42,\"end_byte\":59,\"reason\":\"method_not_in_registry\"}]"}; + cbm_coverage_meta_t meta = {.generation = "fixture", + .index_mode = "full", + .recorded_at = "2026-09-24T00:00:00Z", + .recording_status = "complete", + .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .hash_records_complete = true}; + ASSERT_EQ(cbm_store_coverage_replace_ex(st, proj, &row, 1, &meta), CBM_STORE_OK); + + out = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":" + "\"unresolved-trace\",\"function_name\":\"run\",\"direction\":\"outbound\"," + "\"format\":\"json\"}}}"); + txt = extract_text_content(out); + ASSERT_NOT_NULL(strstr(txt, "\"callees_total_relation\":\"unknown\"")); + free(txt); + free(out); + out = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":" + "\"unresolved-trace\",\"function_name\":\"buscar\",\"direction\":\"inbound\"," + "\"format\":\"json\"}}}"); + txt = extract_text_content(out); + ASSERT_NOT_NULL(strstr(txt, "\"callers_total_relation\":\"unknown\"")); + free(txt); + free(out); + out = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"check_index_coverage\",\"arguments\":{\"project\":" + "\"unresolved-trace\",\"paths\":[\"service.js\"],\"diagnostics\":\"full\"," + "\"format\":\"json\"}}}"); + txt = extract_text_content(out); + ASSERT_NOT_NULL(strstr(txt, "\"status\":\"unresolved_calls\"")); + ASSERT_NOT_NULL(strstr(txt, "method_not_in_registry")); + free(txt); + free(out); + out = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":" + "\"unresolved-trace\",\"function_name\":\"run\",\"direction\":\"outbound\"," + "\"edge_types\":[\"IMPORTS\"],\"format\":\"json\"}}}"); + txt = extract_text_content(out); + ASSERT_NOT_NULL(strstr(txt, "\"callees_total_relation\":\"eq\"")); + free(txt); + free(out); + cbm_mcp_server_free(srv); + PASS(); +} + /* Edge-data lookup must follow the traversal direction and shortest-path hop. * The induced edge set contains a same-hop cross edge whose source is the row * node and sorts before its real inbound predecessor. An arbitrary incident- @@ -20640,6 +20740,7 @@ SUITE(mcp) { RUN_TEST(tool_trace_call_path_prefers_definition); RUN_TEST(trace_evidence_strategy_class_vocabulary_is_closed); RUN_TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped); + RUN_TEST(tool_trace_path_marks_unresolved_call_totals_unknown); RUN_TEST(tool_trace_path_evidence_columns_match_header_issue1542); RUN_TEST(tool_trace_path_unreadable_confidence_reports_not_recorded); RUN_TEST(tool_trace_path_edge_details_use_canonical_predecessor); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 568740f17..9a072cd83 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -765,6 +765,98 @@ TEST(pipeline_adr_survives_full_reindex) { PASS(); } +TEST(pipeline_records_unresolved_injected_call_sites) { + char tmp[256] = "/tmp/cbm_unresolved_calls_XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char db_path[512], path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + snprintf(path, sizeof(path), "%s/cliente.js", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fputs("export function crearCliente() { function buscar(id) { return id; } " + "return { buscar }; }\n", + f); + fclose(f); + snprintf(path, sizeof(path), "%s/servicio.js", tmp); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fputs("export function crearServicio({ cliente }) {\n" + " function procesar(id) { return cliente.buscar(id); }\n" + " return { procesar };\n}\n", + f); + fclose(f); + + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + cbm_store_t *st = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(st); + cbm_coverage_row_t *rows = NULL; + int count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "servicio.js", &rows, &count), CBM_STORE_OK); + bool found = false; + for (int i = 0; i < count; i++) { + if (strcmp(rows[i].kind, "unresolved_calls") == 0 && strstr(rows[i].detail, "buscar") && + strstr(rows[i].detail, "method_not_in_registry")) { + found = true; + } + } + ASSERT_TRUE(found); + cbm_store_free_coverage(rows, count); + cbm_store_close(st); + + /* Reindexing another file must retain this file's diagnostic. */ + snprintf(path, sizeof(path), "%s/cliente.js", tmp); + f = fopen(path, "a"); + ASSERT_NOT_NULL(f); + fputs("\n// unrelated edit\n", f); + fclose(f); + p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + st = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(st); + rows = NULL; + count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "servicio.js", &rows, &count), CBM_STORE_OK); + found = false; + for (int i = 0; i < count; i++) { + found |= strcmp(rows[i].kind, "unresolved_calls") == 0; + } + ASSERT_TRUE(found); + cbm_store_free_coverage(rows, count); + cbm_store_close(st); + + /* The edited file must lose its old diagnostic on the next generation. */ + snprintf(path, sizeof(path), "%s/servicio.js", tmp); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fputs( + "export function crearServicio({ cliente }) { return { procesar(id) { return id; } }; }\n", + f); + fclose(f); + p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + st = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(st); + rows = NULL; + count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "servicio.js", &rows, &count), CBM_STORE_OK); + for (int i = 0; i < count; i++) { + ASSERT_FALSE(strcmp(rows[i].kind, "unresolved_calls") == 0); + } + cbm_store_free_coverage(rows, count); + cbm_store_close(st); + rm_rf(tmp); + PASS(); +} + TEST(pipeline_structure_edges) { if (setup_test_repo() != 0) { FAIL("failed to create temp dir"); @@ -15846,6 +15938,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_committed_counts_match_persisted); RUN_TEST(pipeline_adr_survives_full_reindex); RUN_TEST(pipeline_export_error_snapshot_on_artifact_failure); + RUN_TEST(pipeline_records_unresolved_injected_call_sites); RUN_TEST(pipeline_structure_edges); RUN_TEST(pipeline_branch_root_structure); RUN_TEST(pipeline_project_name_derived); From 3f7f4d78b572f6f2497763e150584b108d51e950 Mon Sep 17 00:00:00 2001 From: Pcristin Date: Mon, 28 Sep 2026 14:57:07 +0000 Subject: [PATCH 03/16] fix(pipeline): reconcile unresolved call coverage Signed-off-by: Pcristin --- internal/cbm/cbm.h | 4 + src/mcp/mcp.c | 252 +++++++++++++++--------- src/pipeline/pass_calls.c | 23 ++- src/pipeline/pass_parallel.c | 83 +++++--- src/pipeline/pipeline.c | 285 ++++++++++++++++++++++------ src/pipeline/pipeline_incremental.c | 13 -- src/store/store.h | 6 +- tests/test_mcp.c | 75 +++++++- tests/test_pipeline.c | 105 ++++++++++ 9 files changed, 641 insertions(+), 205 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 1b5d3f3ef..9b49a3fe4 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -294,6 +294,10 @@ typedef struct { // (self.compiler.apply_converters()). An object the // class owns, not a parameter: read by the weak-member // guard's unique-name exemption. Default false. + /* Ephemeral coverage evidence, set only while resolving this file. Capture + * it before releasing a spilled result; never retain registry pointers. */ + bool coverage_calls_emitted; + const char *coverage_candidate_qn; } CBMCall; typedef struct { diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index f3e700aea..a8acb0b29 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -9046,6 +9046,147 @@ static int clamp_mcp_depth(int depth, const char *tool) { return depth; } +typedef struct { + const cbm_node_t *roots; + int root_count; + const cbm_traverse_result_t *outbound; + const cbm_traverse_result_t *inbound; + bool include_tests; + bool do_outbound; + bool do_inbound; +} trace_coverage_scope_t; + +typedef struct { + CBMHashTable *callers; + CBMHashTable *targets; + bool unresolved_out; + bool unresolved_in; +} trace_coverage_match_t; + +static bool trace_coverage_add_node(CBMHashTable *table, const cbm_node_t *node) { + if (!table || !node->qualified_name) { + return true; + } + cbm_ht_set(table, node->qualified_name, (void *)node); + return cbm_ht_has(table, node->qualified_name); +} + +static bool trace_coverage_add_visited(CBMHashTable *table, const cbm_traverse_result_t *tr, + bool include_tests) { + if (!table) { + return true; + } + for (int i = 0; i < tr->visited_count; i++) { + const cbm_node_t *node = &tr->visited[i].node; + if ((include_tests || !is_test_file(node->file_path)) && + !trace_coverage_add_node(table, node)) { + return false; + } + } + return true; +} + +static bool trace_coverage_build_sets(trace_coverage_match_t *match, + const trace_coverage_scope_t *scope) { + for (int i = 0; i < scope->root_count; i++) { + if (!trace_coverage_add_node(match->callers, &scope->roots[i]) || + !trace_coverage_add_node(match->targets, &scope->roots[i])) { + return false; + } + } + return trace_coverage_add_visited(match->callers, scope->outbound, scope->include_tests) && + trace_coverage_add_visited(match->targets, scope->inbound, scope->include_tests); +} + +static void trace_coverage_match_site(trace_coverage_match_t *match, const char *rel_path, + yyjson_val *site) { + const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller")); + const char *candidate = yyjson_get_str(yyjson_obj_get(site, "candidate")); + const cbm_node_t *caller_node = + match->callers && caller ? cbm_ht_get(match->callers, caller) : NULL; + int line = yyjson_get_int(yyjson_obj_get(site, "line")); + /* The producer joins the exact byte span to the extractor's innermost + * caller. Check its file and source range too; never match a containing + * factory merely because its range overlaps. */ + if (caller_node && caller_node->file_path && strcmp(caller_node->file_path, rel_path) == 0 && + (line <= 0 || (line >= caller_node->start_line && line <= caller_node->end_line))) { + match->unresolved_out = true; + } + /* A short-name collision is not evidence that the resolver considered + * this traced target. */ + if (match->targets && candidate && cbm_ht_has(match->targets, candidate)) { + match->unresolved_in = true; + } +} + +static void trace_coverage_match_row(trace_coverage_match_t *match, const cbm_coverage_row_t *row, + const trace_coverage_scope_t *scope) { + yyjson_doc *detail = yyjson_read(row->detail, strlen(row->detail), 0); + yyjson_val *sites = detail ? yyjson_doc_get_root(detail) : NULL; + if (!yyjson_is_arr(sites)) { + match->unresolved_out = scope->do_outbound; + match->unresolved_in = scope->do_inbound; + } else { + size_t idx; + size_t max; + yyjson_val *site; + yyjson_arr_foreach(sites, idx, max, site) { + trace_coverage_match_site(match, row->rel_path, site); + } + } + if (detail) { + yyjson_doc_free(detail); + } +} + +static bool trace_coverage_scan_rows(cbm_store_t *store, const char *project, + trace_coverage_match_t *match, + const trace_coverage_scope_t *scope) { + cbm_coverage_row_t *rows = NULL; + int row_count = 0; + if (cbm_store_coverage_get_unresolved_calls(store, project, &rows, &row_count) != + CBM_STORE_OK) { + return false; + } + for (int i = 0; i < row_count; i++) { + if ((!scope->do_outbound || match->unresolved_out) && + (!scope->do_inbound || match->unresolved_in)) { + break; + } + trace_coverage_match_row(match, &rows[i], scope); + } + cbm_store_free_coverage(rows, row_count); + return true; +} + +static bool trace_coverage_meta_available(cbm_store_t *store, const char *project) { + cbm_coverage_meta_t meta = {0}; + int rc = cbm_store_coverage_meta_get(store, project, &meta); + bool available = rc == CBM_STORE_OK && + meta.coverage_version >= CBM_UNRESOLVED_CALL_COVERAGE_VERSION && + meta.recording_status && strcmp(meta.recording_status, "unavailable") != 0; + cbm_store_coverage_meta_clear(&meta); + return available; +} + +static void trace_call_coverage(cbm_store_t *store, const char *project, + const trace_coverage_scope_t *scope, bool includes_calls, + bool *unresolved_out, bool *unresolved_in) { + if (!includes_calls) { + return; + } + trace_coverage_match_t match = {.callers = scope->do_outbound ? cbm_ht_create(0) : NULL, + .targets = scope->do_inbound ? cbm_ht_create(0) : NULL}; + bool ok = trace_coverage_meta_available(store, project) && + (!scope->do_outbound || match.callers) && (!scope->do_inbound || match.targets) && + trace_coverage_build_sets(&match, scope) && + trace_coverage_scan_rows(store, project, &match, scope); + *unresolved_out = ok ? match.unresolved_out : scope->do_outbound; + *unresolved_in = ok ? match.unresolved_in : scope->do_inbound; + cbm_ht_free(match.callers); + cbm_ht_free(match.targets); +} + static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { char *func_name = cbm_mcp_get_string_arg(args, "function_name"); char *project = get_project_arg(args); @@ -9381,90 +9522,15 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { } bool unresolved_out = false; bool unresolved_in = false; - if (trace_includes_calls) { - cbm_coverage_meta_t coverage_meta = {0}; - int meta_rc = cbm_store_coverage_meta_get(store, project, &coverage_meta); - if (meta_rc == CBM_STORE_OK) { - if (coverage_meta.coverage_version < CBM_UNRESOLVED_CALL_COVERAGE_VERSION || - !coverage_meta.recording_status || - strcmp(coverage_meta.recording_status, "unavailable") == 0) { - unresolved_out = do_outbound; - unresolved_in = do_inbound; - } - } else { - unresolved_out = do_outbound; - unresolved_in = do_inbound; - } - cbm_store_coverage_meta_clear(&coverage_meta); - if ((do_outbound && !unresolved_out) || (do_inbound && !unresolved_in)) { - CBMHashTable *callers = do_outbound ? cbm_ht_create(0) : NULL; - CBMHashTable *leaves = do_inbound ? cbm_ht_create(0) : NULL; - if ((do_outbound && !callers) || (do_inbound && !leaves)) { - unresolved_out = do_outbound; - unresolved_in = do_inbound; - } else { - bool key_set_failed = false; - for (int i = 0; i < node_count; i++) { - if (callers && nodes[i].qualified_name) { - cbm_ht_set(callers, nodes[i].qualified_name, (void *)1); - key_set_failed |= !cbm_ht_has(callers, nodes[i].qualified_name); - } - if (leaves && nodes[i].name) { - cbm_ht_set(leaves, nodes[i].name, (void *)1); - key_set_failed |= !cbm_ht_has(leaves, nodes[i].name); - } - } - for (int i = 0; callers && i < tr_out.visited_count; i++) { - const cbm_node_t *node = &tr_out.visited[i].node; - if ((include_tests || !is_test_file(node->file_path)) && node->qualified_name) { - cbm_ht_set(callers, node->qualified_name, (void *)1); - key_set_failed |= !cbm_ht_has(callers, node->qualified_name); - } - } - for (int i = 0; leaves && i < tr_in.visited_count; i++) { - const cbm_node_t *node = &tr_in.visited[i].node; - if ((include_tests || !is_test_file(node->file_path)) && node->name) { - cbm_ht_set(leaves, node->name, (void *)1); - key_set_failed |= !cbm_ht_has(leaves, node->name); - } - } - cbm_coverage_row_t *rows = NULL; - int row_count = 0; - if (key_set_failed || cbm_store_coverage_get_unresolved_calls( - store, project, &rows, &row_count) != CBM_STORE_OK) { - unresolved_out = do_outbound; - unresolved_in = do_inbound; - } else { - for (int i = 0; i < row_count && ((!unresolved_out && do_outbound) || - (!unresolved_in && do_inbound)); - i++) { - yyjson_doc *detail = yyjson_read(rows[i].detail, strlen(rows[i].detail), 0); - yyjson_val *sites = detail ? yyjson_doc_get_root(detail) : NULL; - if (!yyjson_is_arr(sites)) { - unresolved_out = do_outbound; - unresolved_in = do_inbound; - } else { - size_t idx, max; - yyjson_val *site; - yyjson_arr_foreach(sites, idx, max, site) { - const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller")); - const char *leaf = yyjson_get_str(yyjson_obj_get(site, "leaf")); - if (callers && caller && cbm_ht_has(callers, caller)) - unresolved_out = true; - if (leaves && leaf && cbm_ht_has(leaves, leaf)) - unresolved_in = true; - } - } - if (detail) - yyjson_doc_free(detail); - } - } - cbm_store_free_coverage(rows, row_count); - } - cbm_ht_free(callers); - cbm_ht_free(leaves); - } - } + trace_coverage_scope_t coverage_scope = {.roots = nodes, + .root_count = node_count, + .outbound = &tr_out, + .inbound = &tr_in, + .include_tests = include_tests, + .do_outbound = do_outbound, + .do_inbound = do_inbound}; + trace_call_coverage(store, project, &coverage_scope, trace_includes_calls, &unresolved_out, + &unresolved_in); const char *out_relation = unresolved_out ? "unknown" : (tr_out.truncated ? "gte" : "eq"); const char *in_relation = unresolved_in ? "unknown" : (tr_in.truncated ? "gte" : "eq"); @@ -10215,11 +10281,12 @@ static bool is_parse_unusable(const cbm_file_error_t *e) { return e->phase && strcmp(e->phase, "parse_unusable") == 0; } -/* Either coverage phase. Both mean the file WAS indexed, so both must stay out +/* All coverage phases mean the file WAS indexed, so they must stay out * of skipped[] — a reader who sees a file there believes it is absent from the * graph entirely. */ -static bool is_parse_coverage(const cbm_file_error_t *e) { - return is_parse_partial(e) || is_parse_unusable(e); +static bool is_indexed_coverage(const cbm_file_error_t *e) { + return is_parse_partial(e) || is_parse_unusable(e) || + (e->phase && strcmp(e->phase, "unresolved_calls") == 0); } /* Attach a summary of per-file skips (Stage 2 / Track B). Always emits a @@ -10229,17 +10296,20 @@ static bool is_parse_coverage(const cbm_file_error_t *e) { * and, if a per-run logfile was written, "logfile": "". * The run status stays "indexed" — a skipped file is the expected handled * outcome, not a failure. errs[] is borrowed (copied into doc) and may contain - * parse_partial and parse_unusable entries, which are filtered out here (both - * reported separately by add_parse_partial_summary). */ + * parse_partial, parse_unusable, and unresolved_calls entries. These describe + * indexed files and are counted separately from skips. */ static void add_skipped_summary(yyjson_mut_doc *doc, yyjson_mut_val *root, const cbm_file_error_t *errs, int count, const char *logfile) { int skips = 0; + int unresolved = 0; for (int i = 0; i < count; i++) { - if (!is_parse_coverage(&errs[i])) { + unresolved += errs[i].phase && strcmp(errs[i].phase, "unresolved_calls") == 0; + if (!is_indexed_coverage(&errs[i])) { skips++; } } yyjson_mut_obj_add_int(doc, root, "skipped_count", skips); + yyjson_mut_obj_add_int(doc, root, "unresolved_calls_count", unresolved); if (logfile && logfile[0]) { yyjson_mut_obj_add_strcpy(doc, root, "logfile", logfile); } @@ -10250,7 +10320,7 @@ static void add_skipped_summary(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_val *files = yyjson_mut_arr(doc); int shown = 0; for (int i = 0; i < count && shown < INDEX_SKIPPED_FILE_CAP; i++) { - if (is_parse_coverage(&errs[i])) { + if (is_indexed_coverage(&errs[i])) { continue; } yyjson_mut_val *fe = yyjson_mut_obj(doc); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 84958954c..e8257f880 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -205,7 +205,7 @@ static void free_import_map(const char **keys, const char **vals, int count) { } /* Handle a route registration call: create Route node + HANDLES edge. */ -static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *call, +static void handle_route_registration(cbm_pipeline_ctx_t *ctx, CBMCall *call, const cbm_gbuf_node_t *source_node, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count) { const char *method = cbm_service_pattern_route_method(call->callee_name); @@ -225,7 +225,9 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn, esc_fa); - cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props); + if (cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props) > 0) { + call->coverage_calls_emitted = true; + } if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') { cbm_resolution_t hres = cbm_registry_resolve(ctx->registry, call->second_arg_name, module_qn, imp_keys, imp_vals, imp_count); @@ -284,7 +286,7 @@ static int64_t create_svc_route_node(cbm_pipeline_ctx_t *ctx, const char *url, c * this, so data_flow mode had no argument expressions to surface for small * (< 50 file) repos that take the sequential path (#514). Mirrors the parallel * path's append_args_json shape so both pipelines agree. */ -static void calls_append_args(char *props, size_t cap, const CBMCall *call) { +static void calls_append_args(char *props, size_t cap, CBMCall *call) { if (!call || call->arg_count <= 0) { return; } @@ -331,7 +333,7 @@ static void calls_append_args(char *props, size_t cap, const CBMCall *call) { } static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const char *type, - char *props, size_t cap, const CBMCall *call) { + char *props, size_t cap, CBMCall *call) { if (call && call->start_line > 0 && strcmp(type, "CALLS") == 0) { size_t len = strlen(props); if (len >= SKIP_ONE && props[len - SKIP_ONE] == '}' && len + CBM_SZ_32 < cap) { @@ -342,10 +344,13 @@ static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const ch if (call && strcmp(type, "CALLS") == 0) { calls_append_args(props, cap, call); } - cbm_gbuf_insert_edge(gbuf, src, tgt, type, props); + if (cbm_gbuf_insert_edge(gbuf, src, tgt, type, props) > 0 && call && + strcmp(type, "CALLS") == 0) { + call->coverage_calls_emitted = true; + } } -static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, +static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target, const cbm_resolution_t *res, cbm_svc_kind_t svc, bool suppress_plain_calls) { @@ -414,7 +419,7 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, * match, #592/#606), the route/HTTP/ASYNC/CONFIG service classifications below * still run — only the plain CALLS fall-through is skipped, so a fabricated * project edge is dropped while every service edge stays main-identical. */ -static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, +static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target, const cbm_resolution_t *res, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, @@ -504,7 +509,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, res.qualified_name = target_node->qualified_name; res.confidence = lsp->confidence; res.strategy = lsp->strategy; - res.candidate_count = 1; + res.candidate_count = CBM_ALLOC_ONE; + call->coverage_candidate_qn = target_node->qualified_name; emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, false); return SKIP_ONE; @@ -682,6 +688,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, if (cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { return 0; } + call->coverage_candidate_qn = target_node->qualified_name; emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, drop_plain_call); return SKIP_ONE; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f..e081c3c3a 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1916,7 +1916,7 @@ static int format_call_arg(char *buf, size_t bufsize, const CBMCallArg *a, const return snprintf(buf, bufsize, "{\"i\":%d,\"e\":\"%s\"}", a->index, esc_e); } -static size_t append_args_json(char *buf, size_t bufsize, size_t pos, const CBMCall *call) { +static size_t append_args_json(char *buf, size_t bufsize, size_t pos, CBMCall *call) { if (call->arg_count == 0 || pos >= bufsize - PP_ARGS_MARGIN) { return pos; } @@ -1972,7 +1972,7 @@ static bool is_route_path_shaped(const char *val) { return val && val[0] == '/' && !cbm_service_pattern_is_comment_text(val); } -static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler) { +static const char *find_route_path_in_args(CBMCall *call, const char **out_handler) { *out_handler = NULL; /* 1. First string arg starting with / */ if (is_route_path_shaped(call->first_string_arg)) { @@ -2012,7 +2012,7 @@ static const char *find_route_path_in_args(const CBMCall *call, const char **out /* Build props JSON, append args, close brace, emit edge. */ static void finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id, - const char *edge_type, char *props, int n, const CBMCall *call) { + const char *edge_type, char *props, int n, CBMCall *call) { if (n > 0 && (size_t)n < CBM_SZ_2K - PP_ESC_SPACE) { size_t pos = append_args_json(props, CBM_SZ_2K, (size_t)n, call); if (call->start_line > 0 && strcmp(edge_type, "CALLS") == 0 && @@ -2027,7 +2027,10 @@ static void finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id, props[pos + SKIP_ONE] = '\0'; } } - cbm_gbuf_insert_edge(gbuf, src_id, tgt_id, edge_type, props); + if (cbm_gbuf_insert_edge(gbuf, src_id, tgt_id, edge_type, props) > 0 && + strcmp(edge_type, "CALLS") == 0) { + call->coverage_calls_emitted = true; + } } /* Build Route node QN and properties for HTTP/async service edges. */ @@ -2057,7 +2060,7 @@ static int64_t build_service_route(cbm_gbuf_t *gbuf, const char *arg, const char /* Emit HTTP_CALLS or ASYNC_CALLS edge via Route node. */ static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const CBMCall *call, const cbm_resolution_t *res, + CBMCall *call, const cbm_resolution_t *res, cbm_svc_kind_t svc, const char *arg) { const char *edge_type = (svc == CBM_SVC_HTTP) ? "HTTP_CALLS" : "ASYNC_CALLS"; const char *method = @@ -2084,7 +2087,7 @@ static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t /* Emit CONFIGURES edge. */ static void emit_config_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const cbm_gbuf_node_t *target, const CBMCall *call, + const cbm_gbuf_node_t *target, CBMCall *call, const cbm_resolution_t *res, const char *arg) { /* emit_service_edge may be reached with target==NULL on the HTTP/ASYNC * external-client bypass (#523); a CONFIGURES edge needs a real target, so @@ -2104,7 +2107,7 @@ static void emit_config_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, /* Emit normal CALLS edge. */ static void emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const cbm_gbuf_node_t *target, const CBMCall *call, + const cbm_gbuf_node_t *target, CBMCall *call, const cbm_resolution_t *res) { /* A CALLS edge needs a real target; the HTTP/ASYNC external-client bypass * (#523) can reach emit_service_edge with target==NULL, so guard the deref. */ @@ -2123,11 +2126,11 @@ static void emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sour /* Classify a resolved call by library identity and emit the appropriate edge. */ /* Create Route node + CALLS + HANDLES edges for a route registration call. */ -static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const CBMCall *call, const char *route_path, - const char *handler_ref, const char *module_qn, - const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, - const char **ik, const char **iv, int ic) { +static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, + const char *route_path, const char *handler_ref, + const char *module_qn, const cbm_registry_t *registry, + const cbm_gbuf_t *main_gbuf, const char **ik, const char **iv, + int ic) { const char *method = cbm_service_pattern_route_method(call->callee_name); char rqn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; @@ -2144,7 +2147,9 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn, esc_rp); - cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props); + if (cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props) > 0) { + call->coverage_calls_emitted = true; + } if (handler_ref && handler_ref[0] != '\0') { cbm_resolution_t hres = cbm_registry_resolve(registry, handler_ref, module_qn, ik, iv, ic); if (hres.qualified_name && hres.qualified_name[0] != '\0') { @@ -2212,8 +2217,7 @@ static bool normalize_url_arg(const char *url, char *norm, int norm_sz) { } /* Detect API paths in call arguments and create HTTP_CALLS edges. */ -static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const CBMCall *call) { +static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call) { for (int ai = 0; ai < call->arg_count; ai++) { const CBMCallArg *ca = &call->args[ai]; /* A slash-prefixed raw expression is not a URL string. In JS/TS this @@ -2318,7 +2322,7 @@ bool extract_grpc_service_method(const char *callee, char *service, size_t srv_s } /* Emit GRPC_CALLS edge via gRPC Route node. */ -static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, +static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, const cbm_resolution_t *res) { char service[CBM_SZ_256]; char method[CBM_SZ_256]; @@ -2355,7 +2359,7 @@ static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons } /* Emit GRAPHQL_CALLS edge. Extract operation from first string arg if available. */ -static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, +static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, const cbm_resolution_t *res) { const char *op = call->first_string_arg; if (!op || !op[0]) { @@ -2392,7 +2396,7 @@ static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, c } /* Emit TRPC_CALLS edge. Extract procedure path from callee chain. */ -static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, +static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, const cbm_resolution_t *res) { /* tRPC calls: trpc.user.getById.query() → extract "user.getById" */ const char *callee = call->callee_name; @@ -2435,7 +2439,7 @@ static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons * a verb-suffix HTTP client (api.patch('/x')), broker, or route registration * keeps its edge; only the fabricated project CALLS edge is dropped. */ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const cbm_gbuf_node_t *target, const CBMCall *call, + const cbm_gbuf_node_t *target, CBMCall *call, const cbm_resolution_t *res, const char *module_qn, const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, const char **imp_keys, const char **imp_vals, int imp_count, @@ -2692,7 +2696,7 @@ static bool lsp_idx_insert_leaf(CBMHashTable *index, CBMResolvedCall *candidate, return inserted; } -static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, const CBMCall *call, +static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, CBMCall *call, bool exact_site, bool *key_built, bool *ambiguous) { if (key_built) { *key_built = false; @@ -3049,6 +3053,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } continue; } + call->coverage_candidate_qn = target_node->qualified_name; _rc_t0 = extract_now_ns(); emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, @@ -3449,6 +3454,28 @@ static bool pp_has_pending_lsp_site(const CBMFileResult *result) { return false; } +static CBMFileResult *resolve_load_result(resolve_ctx_t *rc, resolve_worker_state_t *ws, + int file_idx) { + if (ws->loaded) { + cbm_free_result(ws->loaded); + ws->loaded = NULL; + } + CBMFileResult *result = rc->result_cache[file_idx]; + if (!result && rc->pctx && rc->pctx->spill && cbm_result_spill_has(rc->pctx->spill, file_idx)) { + result = cbm_result_spill_load(rc->pctx->spill, file_idx); + ws->loaded = result; + if (!result) { + cbm_pipeline_mark_unresolved_capture_failed(rc->pctx->pipeline); + } + } + return result; +} + +static void resolve_capture_coverage(const resolve_ctx_t *rc, const char *rel, + const CBMFileResult *result) { + cbm_pipeline_record_unresolved_calls(rc->pctx ? rc->pctx->pipeline : NULL, rel, result); +} + static void resolve_worker(int worker_id, void *ctx_ptr) { resolve_ctx_t *rc = ctx_ptr; resolve_worker_state_t *ws = &rc->workers[worker_id]; @@ -3482,16 +3509,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { uint64_t _loop_t0 = extract_now_ns(); - if (ws->loaded) { - cbm_free_result(ws->loaded); - ws->loaded = NULL; - } - CBMFileResult *result = rc->result_cache[file_idx]; - if (!result && rc->pctx && rc->pctx->spill && - cbm_result_spill_has(rc->pctx->spill, file_idx)) { - result = cbm_result_spill_load(rc->pctx->spill, file_idx); - ws->loaded = result; - } + CBMFileResult *result = resolve_load_result(rc, ws, file_idx); if (!result) { atomic_fetch_add_explicit(&rc->time_ns_total_loop, extract_now_ns() - _loop_t0, memory_order_relaxed); @@ -3547,6 +3565,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { if (result->calls.count == 0 && result->usages.count == 0 && result->throws.count == 0 && result->rw.count == 0 && result->defs.count == 0 && result->impl_traits.count == 0 && !cross_lsp_eligible) { + resolve_capture_coverage(rc, rel, result); continue; } @@ -3695,6 +3714,10 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { atomic_fetch_add_explicit(&rc->time_ns_semantic, extract_now_ns() - _ph_t0, memory_order_relaxed); + /* The cross-file additions and actual emitted-site evidence live in + * this result, including when it was loaded from the spill store. */ + resolve_capture_coverage(rc, rel, result); + cbm_registry_reach_cache_end(); cbm_registry_import_map_cache_end(); cbm_registry_resolve_cache_end(); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 9d12c55d9..3e6952490 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -42,6 +42,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6 }; #include "foundation/mem.h" #include "foundation/mem_core.h" #include "result_spill.h" +#include "cbm.h" #include "foundation/secure_random.h" #include "yyjson/yyjson.h" @@ -265,7 +266,9 @@ struct cbm_pipeline { /* Per-file unresolved invocation diagnostics, published as coverage rows. */ cbm_coverage_row_t *unresolved_rows; int unresolved_count; - bool unresolved_capture_failed; + int unresolved_capacity; + atomic_bool unresolved_capture_failed; + cbm_mutex_t unresolved_mutex; /* one append per file, after parallel resolution */ /* Deterministic test-only seam at the final publication boundary. Kept * per pipeline so concurrent test/process activity cannot cross-trigger. */ @@ -360,6 +363,8 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path, p->committed_nodes = -1; p->committed_edges = -1; atomic_init(&p->cancelled_storage, 0); + atomic_init(&p->unresolved_capture_failed, false); + cbm_mutex_init(&p->unresolved_mutex); p->cancelled = &p->cancelled_storage; return p; @@ -454,63 +459,236 @@ static void unresolved_json_free(void *ctx, void *ptr) { cbm_free(CBM_MEM_CLASS_DUMP, ptr); } -void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path, - const CBMFileResult *result) { - if (!p || !rel_path || !result || result->resolved_calls.count == 0 || - p->unresolved_capture_failed) { - return; +enum { UNRESOLVED_KEY_OVERHEAD = 128, UNRESOLVED_INITIAL_ROWS = 64, UNRESOLVED_ROW_GROWTH = 2 }; + +static char *unresolved_site_key(const char *caller, const char *leaf, uint32_t start, uint32_t end, + CBMSourceOrigin origin) { + size_t cn = strlen(caller); + size_t ln = strlen(leaf); + size_t cap = cn + ln + UNRESOLVED_KEY_OVERHEAD; + char *key = cbm_alloc(CBM_MEM_CLASS_DUMP, cap); + if (key) { + (void)snprintf(key, cap, "%zu:%s|%zu:%s|%u:%u:%u", cn, caller, ln, leaf, start, end, + (unsigned)origin); } - yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - if (!doc) { - p->unresolved_capture_failed = true; - return; + return key; +} + +typedef struct { + const CBMCall *call; +} unresolved_carrier_t; + +typedef struct { + yyjson_mut_doc *doc; + yyjson_mut_val *sites; + CBMHashTable *carriers; + CBMHashTable *seen; +} unresolved_capture_t; + +static void unresolved_carrier_free(const char *key, void *value, void *ctx) { + (void)ctx; + cbm_free(CBM_MEM_CLASS_DUMP, (void *)key); + cbm_free(CBM_MEM_CLASS_DUMP, value); +} + +static void unresolved_key_free(const char *key, void *value, void *ctx) { + (void)value; + (void)ctx; + cbm_free(CBM_MEM_CLASS_DUMP, (void *)key); +} + +static void unresolved_capture_free(unresolved_capture_t *capture) { + if (capture->carriers) { + cbm_ht_foreach(capture->carriers, unresolved_carrier_free, NULL); + cbm_ht_free(capture->carriers); } - yyjson_mut_val *sites = yyjson_mut_arr(doc); - yyjson_mut_doc_set_root(doc, sites); - int count = 0; + if (capture->seen) { + cbm_ht_foreach(capture->seen, unresolved_key_free, NULL); + cbm_ht_free(capture->seen); + } + if (capture->doc) { + yyjson_mut_doc_free(capture->doc); + } +} + +static bool unresolved_call_is_missing(const CBMResolvedCall *rc) { + return rc->kind == CBM_RESOLVED_INVOCATION && rc->strategy && + strcmp(rc->strategy, "lsp_unresolved") == 0 && rc->caller_qn && rc->callee_qn; +} + +static bool unresolved_has_missing_calls(const CBMFileResult *result) { + for (int i = 0; i < result->resolved_calls.count; i++) { + if (unresolved_call_is_missing(&result->resolved_calls.items[i])) { + return true; + } + } + return false; +} + +static bool unresolved_add_carrier(CBMHashTable *carriers, const CBMCall *call) { + if (!call->callee_name || call->site_end_byte <= call->site_start_byte) { + return true; + } + char *key = + unresolved_site_key("", cbm_lsp_bare_segment(call->callee_name), call->site_start_byte, + call->site_end_byte, call->source_origin); + if (!key) { + return false; + } + if (cbm_ht_has(carriers, key)) { + /* Ambiguous extractor occurrences must never erase a diagnostic. */ + unresolved_carrier_t *carrier = cbm_ht_get(carriers, key); + carrier->call = NULL; + cbm_free(CBM_MEM_CLASS_DUMP, key); + return true; + } + unresolved_carrier_t *carrier = cbm_alloc(CBM_MEM_CLASS_DUMP, sizeof(*carrier)); + if (!carrier) { + cbm_free(CBM_MEM_CLASS_DUMP, key); + return false; + } + carrier->call = call; + cbm_ht_set(carriers, key, carrier); + if (!cbm_ht_has(carriers, key)) { + cbm_free(CBM_MEM_CLASS_DUMP, key); + cbm_free(CBM_MEM_CLASS_DUMP, carrier); + return false; + } + return true; +} + +static bool unresolved_build_carriers(CBMHashTable *carriers, const CBMFileResult *result) { + /* Caller names in the TS LSP walk can denote an outer factory. The + * extractor's exact occurrence is the authoritative enclosing function. + * Index by span AND leaf: two receiver calls on one line remain distinct. */ + for (int i = 0; i < result->calls.count; i++) { + if (!unresolved_add_carrier(carriers, &result->calls.items[i])) { + return false; + } + } + return true; +} + +static bool unresolved_append_site(unresolved_capture_t *capture, const CBMResolvedCall *rc, + const CBMCall *call, const char *caller, const char *leaf) { + yyjson_mut_doc *doc = capture->doc; + yyjson_mut_val *site = yyjson_mut_obj(doc); + bool ok = + site && yyjson_mut_obj_add_strcpy(doc, site, "caller", caller) && + yyjson_mut_obj_add_strcpy(doc, site, "leaf", leaf) && + yyjson_mut_obj_add_uint(doc, site, "start_byte", rc->site_start_byte) && + yyjson_mut_obj_add_uint(doc, site, "end_byte", rc->site_end_byte) && + yyjson_mut_obj_add_uint(doc, site, "source_origin", rc->source_origin) && + yyjson_mut_obj_add_strcpy(doc, site, "reason", rc->reason ? rc->reason : "unresolved"); + if (ok && call && call->start_line > 0) { + ok = yyjson_mut_obj_add_int(doc, site, "line", call->start_line); + } + if (ok && call && call->coverage_candidate_qn) { + ok = yyjson_mut_obj_add_strcpy(doc, site, "candidate", call->coverage_candidate_qn); + } + return ok && yyjson_mut_arr_add_val(capture->sites, site); +} + +static bool unresolved_collect_site(unresolved_capture_t *capture, const CBMResolvedCall *rc) { + const char *leaf = cbm_lsp_bare_segment(rc->callee_qn); + char *lookup = + unresolved_site_key("", leaf, rc->site_start_byte, rc->site_end_byte, rc->source_origin); + if (!lookup) { + return false; + } + const unresolved_carrier_t *carrier = cbm_ht_get(capture->carriers, lookup); + const CBMCall *call = carrier ? carrier->call : NULL; + cbm_free(CBM_MEM_CLASS_DUMP, lookup); + /* This exact occurrence emitted a real CALLS edge, possibly via the + * registry after LSP failure. Never suppress a different occurrence. */ + if (call && call->coverage_calls_emitted) { + return true; + } + const char *caller = call && call->enclosing_func_qn ? call->enclosing_func_qn : rc->caller_qn; + char *key = unresolved_site_key(caller, leaf, rc->site_start_byte, rc->site_end_byte, + rc->source_origin); + if (!key) { + return false; + } + if (cbm_ht_has(capture->seen, key)) { + cbm_free(CBM_MEM_CLASS_DUMP, key); + return true; + } + cbm_ht_set(capture->seen, key, key); + if (!cbm_ht_has(capture->seen, key)) { + cbm_free(CBM_MEM_CLASS_DUMP, key); + return false; + } + return unresolved_append_site(capture, rc, call, caller, leaf); +} + +static bool unresolved_collect_sites(unresolved_capture_t *capture, const CBMFileResult *result) { for (int i = 0; i < result->resolved_calls.count; i++) { const CBMResolvedCall *rc = &result->resolved_calls.items[i]; - if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->strategy || - strcmp(rc->strategy, "lsp_unresolved") != 0 || !rc->caller_qn || !rc->callee_qn) { - continue; + if (unresolved_call_is_missing(rc) && !unresolved_collect_site(capture, rc)) { + return false; } - const char *leaf = strrchr(rc->callee_qn, '.'); - leaf = leaf ? leaf + 1 : rc->callee_qn; - yyjson_mut_val *site = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, site, "caller", rc->caller_qn); - yyjson_mut_obj_add_strcpy(doc, site, "leaf", leaf); - yyjson_mut_obj_add_uint(doc, site, "start_byte", rc->site_start_byte); - yyjson_mut_obj_add_uint(doc, site, "end_byte", rc->site_end_byte); - yyjson_mut_obj_add_strcpy(doc, site, "reason", rc->reason ? rc->reason : "unresolved"); - yyjson_mut_arr_add_val(sites, site); - count++; } - if (count > 0) { - yyjson_alc allocator = {.malloc = unresolved_json_alloc, - .realloc = unresolved_json_realloc, - .free = unresolved_json_free}; - char *detail = yyjson_mut_write_opts(doc, 0, &allocator, NULL, NULL); + return true; +} + +/* Caller holds unresolved_mutex. Ownership transfers only on success. */ +static bool unresolved_append_row(cbm_pipeline_t *p, const char *path, const char *detail) { + if (!detail || !path || p->unresolved_capture_failed) { + return false; + } + if (p->unresolved_count == p->unresolved_capacity) { + int capacity = p->unresolved_capacity ? p->unresolved_capacity * UNRESOLVED_ROW_GROWTH + : UNRESOLVED_INITIAL_ROWS; + cbm_coverage_row_t *rows = + cbm_realloc(CBM_MEM_CLASS_DUMP, p->unresolved_rows, (size_t)capacity * sizeof(*rows)); + if (!rows) { + return false; + } + p->unresolved_rows = rows; + p->unresolved_capacity = capacity; + } + p->unresolved_rows[p->unresolved_count++] = + (cbm_coverage_row_t){.rel_path = path, .kind = "unresolved_calls", .detail = detail}; + return true; +} + +void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path, + const CBMFileResult *result) { + if (!p || !rel_path || !result || p->unresolved_capture_failed) { + return; + } + if (!unresolved_has_missing_calls(result)) { + return; + } + yyjson_alc allocator = {.malloc = unresolved_json_alloc, + .realloc = unresolved_json_realloc, + .free = unresolved_json_free}; + unresolved_capture_t capture = {.doc = yyjson_mut_doc_new(&allocator), + .carriers = cbm_ht_create(0), + .seen = cbm_ht_create(0)}; + capture.sites = capture.doc ? yyjson_mut_arr(capture.doc) : NULL; + bool ok = capture.doc && capture.carriers && capture.seen && capture.sites; + if (capture.doc) { + yyjson_mut_doc_set_root(capture.doc, capture.sites); + } + ok = ok && unresolved_build_carriers(capture.carriers, result) && + unresolved_collect_sites(&capture, result); + if (ok && yyjson_mut_arr_size(capture.sites) > 0) { + char *detail = yyjson_mut_write_opts(capture.doc, 0, &allocator, NULL, NULL); char *path = cbm_mem_strdup(CBM_MEM_CLASS_DUMP, rel_path); - if (!detail || !path) { - p->unresolved_capture_failed = true; + cbm_mutex_lock(&p->unresolved_mutex); + ok = unresolved_append_row(p, path, detail); + cbm_mutex_unlock(&p->unresolved_mutex); + if (!ok) { cbm_free(CBM_MEM_CLASS_DUMP, detail); cbm_free(CBM_MEM_CLASS_DUMP, path); - } else { - cbm_coverage_row_t *rows = - cbm_realloc(CBM_MEM_CLASS_DUMP, p->unresolved_rows, - (size_t)(p->unresolved_count + 1) * sizeof(*rows)); - if (!rows) { - p->unresolved_capture_failed = true; - cbm_free(CBM_MEM_CLASS_DUMP, detail); - cbm_free(CBM_MEM_CLASS_DUMP, path); - } else { - p->unresolved_rows = rows; - p->unresolved_rows[p->unresolved_count++] = (cbm_coverage_row_t){ - .rel_path = path, .kind = "unresolved_calls", .detail = detail}; - } } } - yyjson_mut_doc_free(doc); + if (!ok) { + p->unresolved_capture_failed = true; + } + unresolved_capture_free(&capture); } void cbm_pipeline_mark_unresolved_capture_failed(cbm_pipeline_t *p) { @@ -563,6 +741,7 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { cbm_free(CBM_MEM_CLASS_DUMP, (char *)p->unresolved_rows[i].detail); } cbm_free(CBM_MEM_CLASS_DUMP, p->unresolved_rows); + cbm_mutex_destroy(&p->unresolved_mutex); cbm_git_context_free(&p->git_ctx); /* gbuf, store, registry freed during/after run */ /* Defensively free userconfig in case run() was never called or panicked */ @@ -1799,18 +1978,6 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, cbm_clock_gettime(CLOCK_MONOTONIC, t); rc = cbm_parallel_resolve(ctx, files, file_count, cache, &shared_ids, worker_count, all_defs, def_count, def_modules, module_def_index, &cross_registries); - if (rc == 0) { - for (int i = 0; i < file_count; i++) { - bool loaded = false; - CBMFileResult *result = cbm_pipeline_result_acquire(ctx, cache, i, NULL, &loaded); - if (result) { - cbm_pipeline_record_unresolved_calls(p, files[i].rel_path, result); - } else if (ctx->spill && cbm_result_spill_has(ctx->spill, i)) { - cbm_pipeline_mark_unresolved_capture_failed(p); - } - cbm_pipeline_result_release(result, loaded); - } - } cbm_log_info("pass.timing", "pass", "parallel_resolve", "elapsed_ms", itoa_buf((int)elapsed_ms(*t))); pipeline_phase_mark("parallel_resolve"); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index f0953764e..9d34ff241 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1352,19 +1352,6 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed rc = cbm_parallel_resolve(ctx, changed_files, ci, cache, &shared_ids, worker_count, all_defs, all_def_count, closure ? closure->def_modules : NULL, module_def_index, registries_arg); - if (rc == 0) { - for (int i = 0; i < ci; i++) { - bool loaded = false; - CBMFileResult *result = cbm_pipeline_result_acquire(ctx, cache, i, NULL, &loaded); - if (result) { - cbm_pipeline_record_unresolved_calls(ctx->pipeline, changed_files[i].rel_path, - result); - } else if (ctx->spill && cbm_result_spill_has(ctx->spill, i)) { - cbm_pipeline_mark_unresolved_capture_failed(ctx->pipeline); - } - cbm_pipeline_result_release(result, loaded); - } - } if (module_def_index) { cbm_pxc_free_module_def_index(module_def_index); } diff --git a/src/store/store.h b/src/store/store.h index 82489255e..2163eed09 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -650,9 +650,9 @@ typedef struct { const char *detail; } cbm_coverage_row_t; -/* Older generations have no unresolved-call records, so their exact trace - * totals cannot be trusted after this signal becomes part of coverage. */ -enum { CBM_UNRESOLVED_CALL_COVERAGE_VERSION = 4 }; +/* Older generations lack precise caller attribution and resolver candidates, + * so their exact trace totals cannot be trusted until the project is reindexed. */ +enum { CBM_UNRESOLVED_CALL_COVERAGE_VERSION = 5 }; /* Metadata describing how completely one index run recorded the best-effort * coverage signal. `recording_status` is "complete", "truncated", or diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 02cb9db01..2f8b41eb2 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -7462,7 +7462,9 @@ TEST(tool_trace_path_marks_unresolved_call_totals_unknown) { .rel_path = "service.js", .kind = "unresolved_calls", .detail = "[{\"caller\":\"unresolved-trace.service.run\",\"leaf\":\"buscar\"," - "\"start_byte\":42,\"end_byte\":59,\"reason\":\"method_not_in_registry\"}]"}; + "\"start_byte\":42,\"end_byte\":59,\"line\":3," + "\"candidate\":\"unresolved-trace.client.buscar\"," + "\"reason\":\"method_not_in_registry\"}]"}; cbm_coverage_meta_t meta = {.generation = "fixture", .index_mode = "full", .recorded_at = "2026-09-24T00:00:00Z", @@ -7489,6 +7491,20 @@ TEST(tool_trace_path_marks_unresolved_call_totals_unknown) { ASSERT_NOT_NULL(strstr(txt, "\"callers_total_relation\":\"unknown\"")); free(txt); free(out); + /* The same leaf in an unrelated module was never a resolver candidate. */ + cbm_node_t unrelated = callee; + unrelated.qualified_name = "unresolved-trace.other.buscar"; + unrelated.file_path = "other.js"; + ASSERT_GT(cbm_store_upsert_node(st, &unrelated), 0); + out = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":" + "\"unresolved-trace\",\"function_name\":\"unresolved-trace.other.buscar\"," + "\"direction\":\"inbound\",\"format\":\"json\"}}}"); + txt = extract_text_content(out); + ASSERT_NOT_NULL(strstr(txt, "\"callers_total_relation\":\"eq\"")); + free(txt); + free(out); out = cbm_mcp_server_handle( srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," "\"params\":{\"name\":\"check_index_coverage\",\"arguments\":{\"project\":" @@ -7512,6 +7528,62 @@ TEST(tool_trace_path_marks_unresolved_call_totals_unknown) { PASS(); } +TEST(tool_index_unresolved_nested_functions_are_indexed) { + char tmp[256] = "/tmp/cbm-mcp-unresolved-XXXXXX"; + char cache[256] = "/tmp/cbm-mcp-unresolved-cache-XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + char path[512]; + snprintf(path, sizeof(path), "%s/service.js", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fputs("export function factory({ client }) {\n" + " function inner(id) {\n" + " return client.buscar(id);\n" + " }\n return { inner };\n}\n", + f); + fclose(f); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", tmp); + char *out = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(out); + ASSERT(response_contains_json_fragment(out, "\"status\":\"indexed\"")); + ASSERT(response_contains_json_fragment(out, "\"skipped_count\":0")); + free(out); + char *project = cbm_project_name_from_path(tmp); + ASSERT_NOT_NULL(project); + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"inner\"," + "\"direction\":\"outbound\",\"format\":\"json\"}", + project); + out = cbm_mcp_handle_tool(srv, "trace_path", args); + ASSERT_NOT_NULL(out); + ASSERT(response_contains_json_fragment(out, "\"callees_total_relation\":\"unknown\"")); + free(out); + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"factory\"," + "\"direction\":\"outbound\",\"format\":\"json\"}", + project); + out = cbm_mcp_handle_tool(srv, "trace_path", args); + ASSERT_NOT_NULL(out); + ASSERT(response_contains_json_fragment(out, "\"callees_total_relation\":\"eq\"")); + free(out); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(path); + cbm_rmdir(tmp); + cbm_rmdir(cache); + PASS(); +} + /* Edge-data lookup must follow the traversal direction and shortest-path hop. * The induced edge set contains a same-hop cross edge whose source is the row * node and sorts before its real inbound predecessor. An arbitrary incident- @@ -20741,6 +20813,7 @@ SUITE(mcp) { RUN_TEST(trace_evidence_strategy_class_vocabulary_is_closed); RUN_TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped); RUN_TEST(tool_trace_path_marks_unresolved_call_totals_unknown); + RUN_TEST(tool_index_unresolved_nested_functions_are_indexed); RUN_TEST(tool_trace_path_evidence_columns_match_header_issue1542); RUN_TEST(tool_trace_path_unreadable_confidence_reports_not_recorded); RUN_TEST(tool_trace_path_edge_details_use_canonical_predecessor); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9a072cd83..fd8ddd127 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -765,6 +765,108 @@ TEST(pipeline_adr_survives_full_reindex) { PASS(); } +static int pipeline_unresolved_review_case(int padding, bool spill) { + char tmp[256] = "/tmp/cbm_unresolved_review_XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char db[512], path[512]; + const char *names[] = {"cliente.js", "servicio.js", "ayudante.js", + "directo.js", "missing.js", "route.js"}; + const char *sources[] = { + "export function crearCliente() { function buscar(id) { return id; } return { buscar }; " + "}\n", + "export function crearServicio({ cliente }) {\n" + " function procesar(id) {\n" + " cliente.buscar(id); return cliente.buscar(id + 1);\n" + " }\n return { procesar };\n}\n", + "export function ayudante(id) { return id; }\n", + "import { ayudante } from './ayudante.js';\n" + "export function usarDirecto(id) { ayudante(id); return ayudante(id + 1); }\n", + "import { absent } from './ayudante.js';\n" + "export function missing(id) { return absent(id); }\n", + "export function setup({ app }) { app.get('/x', handler); }\n" + "function handler() { return 1; }\n"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + snprintf(path, sizeof(path), "%s/%s", tmp, names[i]); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fputs(sources[i], f); + fclose(f); + } + for (int i = 0; i < padding; i++) { + snprintf(path, sizeof(path), "%s/pad%d.js", tmp, i); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "export function pad%d() { return %d; }\n", i, i); + fclose(f); + } + snprintf(db, sizeof(db), "%s/index.db", tmp); + if (spill) + cbm_setenv("CBM_MEM_SPILL", "1", 1); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + int rc = cbm_pipeline_run(p); + if (spill) + cbm_unsetenv("CBM_MEM_SPILL"); + ASSERT_EQ(rc, 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + cbm_store_t *st = cbm_store_open_path(db); + ASSERT_NOT_NULL(st); + cbm_coverage_row_t *rows = NULL; + int count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "directo.js", &rows, &count), CBM_STORE_OK); + for (int i = 0; i < count; i++) + ASSERT_FALSE(strcmp(rows[i].kind, "unresolved_calls") == 0); + cbm_store_free_coverage(rows, count); + rows = NULL; + count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "route.js", &rows, &count), CBM_STORE_OK); + for (int i = 0; i < count; i++) + ASSERT_FALSE(strcmp(rows[i].kind, "unresolved_calls") == 0); + cbm_store_free_coverage(rows, count); + rows = NULL; + count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "servicio.js", &rows, &count), CBM_STORE_OK); + ASSERT_EQ(count, 1); + yyjson_doc *doc = yyjson_read(rows[0].detail, strlen(rows[0].detail), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *sites = yyjson_doc_get_root(doc); + ASSERT_EQ(yyjson_arr_size(sites), 2); + for (size_t i = 0; i < 2; i++) { + yyjson_val *site = yyjson_arr_get(sites, i); + const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller")); + ASSERT_NOT_NULL(caller); + ASSERT_NOT_NULL(strstr(caller, ".procesar")); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(site, "line")), 3); + ASSERT_NOT_NULL(yyjson_get_str(yyjson_obj_get(site, "candidate"))); + } + yyjson_doc_free(doc); + cbm_store_free_coverage(rows, count); + rows = NULL; + count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "missing.js", &rows, &count), CBM_STORE_OK); + ASSERT_EQ(count, 1); + doc = yyjson_read(rows[0].detail, strlen(rows[0].detail), 0); + ASSERT_NOT_NULL(doc); + ASSERT_EQ(yyjson_arr_size(yyjson_doc_get_root(doc)), 1); + yyjson_doc_free(doc); + cbm_store_free_coverage(rows, count); + cbm_store_close(st); + rm_rf(tmp); + PASS(); +} + +TEST(pipeline_unresolved_review_sequential) { + return pipeline_unresolved_review_case(0, false); +} +TEST(pipeline_unresolved_review_parallel) { + return pipeline_unresolved_review_case(55, false); +} +TEST(pipeline_unresolved_review_spill) { + return pipeline_unresolved_review_case(55, true); +} + TEST(pipeline_records_unresolved_injected_call_sites) { char tmp[256] = "/tmp/cbm_unresolved_calls_XXXXXX"; ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); @@ -15939,6 +16041,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_adr_survives_full_reindex); RUN_TEST(pipeline_export_error_snapshot_on_artifact_failure); RUN_TEST(pipeline_records_unresolved_injected_call_sites); + RUN_TEST(pipeline_unresolved_review_sequential); + RUN_TEST(pipeline_unresolved_review_parallel); + RUN_TEST(pipeline_unresolved_review_spill); RUN_TEST(pipeline_structure_edges); RUN_TEST(pipeline_branch_root_structure); RUN_TEST(pipeline_project_name_derived); From 8d80be7bdef118ac4430464a0f99715680340673 Mon Sep 17 00:00:00 2001 From: Pcristin Date: Tue, 29 Sep 2026 09:09:57 +0000 Subject: [PATCH 04/16] fix(mcp): limit unresolved outbound totals to project symbols Signed-off-by: Pcristin --- src/mcp/mcp.c | 31 +++++++++++++++-- tests/test_mcp.c | 88 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index a8acb0b29..cf2b276cb 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -9057,6 +9057,8 @@ typedef struct { } trace_coverage_scope_t; typedef struct { + cbm_store_t *store; + const char *project; CBMHashTable *callers; CBMHashTable *targets; bool unresolved_out; @@ -9098,9 +9100,31 @@ static bool trace_coverage_build_sets(trace_coverage_match_t *match, trace_coverage_add_visited(match->targets, scope->inbound, scope->include_tests); } +static bool trace_coverage_may_target_project(const trace_coverage_match_t *match, + const char *name) { + if (!name || !name[0]) { + return true; + } + cbm_node_t *nodes = NULL; + int count = 0; + int rc = cbm_store_find_nodes_by_name(match->store, match->project, name, &nodes, &count); + /* An unsuccessful lookup cannot prove that the callee is outside the + * project. Free the allocated result even when it contains zero rows. */ + bool possible = rc != CBM_STORE_OK; + for (int i = 0; i < count && !possible; i++) { + const char *label = nodes[i].label; + possible = label && strcmp(label, "File") != 0 && strcmp(label, "Folder") != 0 && + strcmp(label, "Project") != 0 && strcmp(label, "Module") != 0 && + strcmp(label, "Package") != 0 && strcmp(label, "Section") != 0; + } + cbm_store_free_nodes(nodes, count); + return possible; +} + static void trace_coverage_match_site(trace_coverage_match_t *match, const char *rel_path, yyjson_val *site) { const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller")); + const char *leaf = yyjson_get_str(yyjson_obj_get(site, "leaf")); const char *candidate = yyjson_get_str(yyjson_obj_get(site, "candidate")); const cbm_node_t *caller_node = match->callers && caller ? cbm_ht_get(match->callers, caller) : NULL; @@ -9109,7 +9133,8 @@ static void trace_coverage_match_site(trace_coverage_match_t *match, const char * caller. Check its file and source range too; never match a containing * factory merely because its range overlaps. */ if (caller_node && caller_node->file_path && strcmp(caller_node->file_path, rel_path) == 0 && - (line <= 0 || (line >= caller_node->start_line && line <= caller_node->end_line))) { + (line <= 0 || (line >= caller_node->start_line && line <= caller_node->end_line)) && + trace_coverage_may_target_project(match, leaf)) { match->unresolved_out = true; } /* A short-name collision is not evidence that the resolver considered @@ -9175,7 +9200,9 @@ static void trace_call_coverage(cbm_store_t *store, const char *project, if (!includes_calls) { return; } - trace_coverage_match_t match = {.callers = scope->do_outbound ? cbm_ht_create(0) : NULL, + trace_coverage_match_t match = {.store = store, + .project = project, + .callers = scope->do_outbound ? cbm_ht_create(0) : NULL, .targets = scope->do_inbound ? cbm_ht_create(0) : NULL}; bool ok = trace_coverage_meta_available(store, project) && (!scope->do_outbound || match.callers) && (!scope->do_inbound || match.targets) && diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 2f8b41eb2..f91fd6897 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -7528,6 +7528,90 @@ TEST(tool_trace_path_marks_unresolved_call_totals_unknown) { PASS(); } +TEST(tool_trace_path_outbound_requires_project_symbol) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "project-symbol-trace"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/project-symbol-trace"), CBM_STORE_OK); + cbm_node_t caller = {.project = proj, + .label = "Function", + .name = "run", + .qualified_name = "project-symbol-trace.service.run", + .file_path = "service.js", + .start_line = 1, + .end_line = 4}; + cbm_node_t local = {.project = proj, + .label = "Function", + .name = "buscar", + .qualified_name = "project-symbol-trace.client.buscar", + .file_path = "client.js"}; + cbm_node_t constructor = {.project = proj, + .label = "Class", + .name = "Cliente", + .qualified_name = "project-symbol-trace.client.Cliente", + .file_path = "client.js"}; + cbm_node_t foreign = {.project = "other-project", + .label = "Function", + .name = "foreignOnly", + .qualified_name = "other-project.client.foreignOnly", + .file_path = "client.js"}; + ASSERT_GT(cbm_store_upsert_node(st, &caller), 0); + ASSERT_GT(cbm_store_upsert_node(st, &local), 0); + ASSERT_GT(cbm_store_upsert_node(st, &constructor), 0); + ASSERT_EQ(cbm_store_upsert_project(st, foreign.project, "/tmp/other-project"), CBM_STORE_OK); + ASSERT_GT(cbm_store_upsert_node(st, &foreign), 0); + const char *containers[] = {"File", "Folder", "Project", "Module", "Package", "Section"}; + for (size_t i = 0; i < sizeof(containers) / sizeof(containers[0]); i++) { + char name[64], qn[128]; + snprintf(name, sizeof(name), "external%s", containers[i]); + snprintf(qn, sizeof(qn), "%s.%s", proj, name); + cbm_node_t container = { + .project = proj, .label = containers[i], .name = name, .qualified_name = qn}; + ASSERT_GT(cbm_store_upsert_node(st, &container), 0); + } + ASSERT_EQ(cbm_store_upsert_file_hash(st, proj, "service.js", "fixture", 0, 0), CBM_STORE_OK); + cbm_coverage_meta_t meta = {.generation = "fixture", + .index_mode = "full", + .recorded_at = "2026-09-29T00:00:00Z", + .recording_status = "complete", + .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .hash_records_complete = true}; + const struct { + const char *leaf; + const char *relation; + } cases[] = {{"printf", "eq"}, {"foreignOnly", "eq"}, {"Buscar", "eq"}, + {"externalFile", "eq"}, {"externalFolder", "eq"}, {"externalProject", "eq"}, + {"externalModule", "eq"}, {"externalPackage", "eq"}, {"externalSection", "eq"}, + {"buscar", "unknown"}, {"Cliente", "unknown"}}; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char detail[512]; + snprintf(detail, sizeof(detail), + "[{\"caller\":\"project-symbol-trace.service.run\",\"leaf\":\"%s\"," + "\"start_byte\":42,\"end_byte\":59,\"line\":3," + "\"reason\":\"method_not_in_registry\"}]", + cases[i].leaf); + cbm_coverage_row_t row = { + .rel_path = "service.js", .kind = "unresolved_calls", .detail = detail}; + ASSERT_EQ(cbm_store_coverage_replace_ex(st, proj, &row, 1, &meta), CBM_STORE_OK); + char *out = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":" + "\"project-symbol-trace\",\"function_name\":\"run\"," + "\"direction\":\"outbound\",\"format\":\"json\"}}}"); + char *txt = extract_text_content(out); + char expected[80]; + snprintf(expected, sizeof(expected), "\"callees_total_relation\":\"%s\"", + cases[i].relation); + ASSERT_NOT_NULL(strstr(txt, expected)); + ASSERT_NOT_NULL(strstr(txt, "\"callees_total\":0")); + free(txt); + free(out); + } + cbm_mcp_server_free(srv); + PASS(); +} + TEST(tool_index_unresolved_nested_functions_are_indexed) { char tmp[256] = "/tmp/cbm-mcp-unresolved-XXXXXX"; char cache[256] = "/tmp/cbm-mcp-unresolved-cache-XXXXXX"; @@ -7540,7 +7624,8 @@ TEST(tool_index_unresolved_nested_functions_are_indexed) { snprintf(path, sizeof(path), "%s/service.js", tmp); FILE *f = fopen(path, "w"); ASSERT_NOT_NULL(f); - fputs("export function factory({ client }) {\n" + fputs("export class Client { buscar(id) { return id; } }\n" + "export function factory({ client }) {\n" " function inner(id) {\n" " return client.buscar(id);\n" " }\n return { inner };\n}\n", @@ -20813,6 +20898,7 @@ SUITE(mcp) { RUN_TEST(trace_evidence_strategy_class_vocabulary_is_closed); RUN_TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped); RUN_TEST(tool_trace_path_marks_unresolved_call_totals_unknown); + RUN_TEST(tool_trace_path_outbound_requires_project_symbol); RUN_TEST(tool_index_unresolved_nested_functions_are_indexed); RUN_TEST(tool_trace_path_evidence_columns_match_header_issue1542); RUN_TEST(tool_trace_path_unreadable_confidence_reports_not_recorded); From 42971f2c023db2ec4e1a0fa011f983dec9d67fb0 Mon Sep 17 00:00:00 2001 From: BumaldaOverTheWater94 <83429948+BumaldaOverTheWater94@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:22:18 +0000 Subject: [PATCH 05/16] fix(cli): trust root-owned per-user /home entries during activation walk Managed Linux hosts keep /home a real directory and point /home/ at another tree (e.g. /local/home/). The O_NOFOLLOW walk rejected that entry and install failed with 'activation transaction I/O failed'. Resolve /home/ only when /home is root-owned and not group/world writable, the entry is a root-owned symlink, and it resolves to a directory owned by root or the current user. Arbitrary user-owned symlinks are still rejected. Fixes #2306 Co-authored-by: Claude Signed-off-by: BumaldaOverTheWater94 <83429948+BumaldaOverTheWater94@users.noreply.github.com> --- src/cli/activation_transaction.c | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/cli/activation_transaction.c b/src/cli/activation_transaction.c index a56052775..fb7b5983a 100644 --- a/src/cli/activation_transaction.c +++ b/src/cli/activation_transaction.c @@ -889,6 +889,53 @@ static bool activation_posix_acl_empty(int descriptor) { return cbm_macos_extended_acl_fd_is_empty(descriptor); } +#ifdef __linux__ +/* Managed Linux hosts often keep /home a real directory and point the per-user + * entry elsewhere (/home/alice -> /local/home/alice). Trust that entry only + * when root owns it inside a /home nobody else can write, and it resolves to a + * directory owned by root or the current user. Returns NULL when not + * applicable; the O_NOFOLLOW walk then rejects the path as before. */ +static char *activation_linux_home_entry_path(const char *directory) { + static const char home_prefix[] = "/home/"; + size_t prefix_length = sizeof(home_prefix) - 1U; + if (strncmp(directory, home_prefix, prefix_length) != 0) { + return NULL; + } + size_t name_length = strcspn(directory + prefix_length, "/"); + char entry[sizeof(home_prefix) + 256U]; + if (name_length == 0 || name_length >= 256U) { + return NULL; + } + memcpy(entry, directory, prefix_length + name_length); + entry[prefix_length + name_length] = '\0'; + + struct stat home_status; + struct stat entry_status; + struct stat resolved_status; + char resolved[4096]; + if (lstat("/home", &home_status) != 0 || !S_ISDIR(home_status.st_mode) || + home_status.st_uid != 0 || (home_status.st_mode & 0022) != 0 || + lstat(entry, &entry_status) != 0 || !S_ISLNK(entry_status.st_mode) || + entry_status.st_uid != 0 || !realpath(entry, resolved) || + lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode) || + (resolved_status.st_uid != 0 && resolved_status.st_uid != geteuid())) { + return NULL; + } + const char *rest = directory + prefix_length + name_length; + size_t needed = strlen(resolved) + strlen(rest) + 1U; + char *mapped = malloc(needed); + if (!mapped) { + return NULL; + } + int written = snprintf(mapped, needed, "%s%s", resolved, rest); + if (written <= 0 || (size_t)written >= needed) { + free(mapped); + return NULL; + } + return mapped; +} +#endif + static char *activation_posix_walk_path(const char *directory) { #if defined(__APPLE__) || defined(__linux__) /* macOS and immutable Linux layouts can expose writable trees through @@ -935,6 +982,12 @@ static char *activation_posix_walk_path(const char *directory) { } return mapped; } +#endif +#ifdef __linux__ + char *home_entry = activation_linux_home_entry_path(directory); + if (home_entry) { + return home_entry; + } #endif return activation_string_copy(directory); } From 2fbf3fb977c02c5fed5b2b8b6796902a270bc1c5 Mon Sep 17 00:00:00 2001 From: Glen Ko Date: Mon, 28 Sep 2026 21:24:43 +0000 Subject: [PATCH 06/16] fix(cli): fold per-user /home entry into the alias loop, add test seam Address review on #2309: - Fold the /home/ handling into the existing #2175 alias loop via a shared activation_alias_map() that builds the mapped path in a stack buffer and returns activation_string_copy(); raw allocator sites in activation_transaction.c drop from 80 to 78 (baseline lowered). - Comment the deliberate difference: the per-user entry may resolve to a directory owned by the current account, the whole-alias case may not. - Add cbm_activation_transaction_set_home_root_for_testing() (test seams only) and a Linux test that fails without the per-user branch. Co-authored-by: Claude Signed-off-by: Glen Ko --- scripts/memory-core-baseline.txt | 2 +- src/cli/activation_transaction.c | 122 ++++++++++++++-------------- src/cli/activation_transaction.h | 5 ++ tests/test_activation_transaction.c | 90 ++++++++++++++++++++ 4 files changed, 159 insertions(+), 60 deletions(-) diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index 138ef0f77..5bbae344a 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -14,7 +14,7 @@ internal/cbm/lsp/py_lsp.c 2 internal/cbm/macro_table.c 1 internal/cbm/service_patterns.c 2 internal/cbm/sqlite_writer.c 74 -src/cli/activation_transaction.c 80 +src/cli/activation_transaction.c 78 src/cli/agent_clients.c 73 src/cli/agent_profiles.c 7 src/cli/cli.c 170 diff --git a/src/cli/activation_transaction.c b/src/cli/activation_transaction.c index fb7b5983a..f526b2c9c 100644 --- a/src/cli/activation_transaction.c +++ b/src/cli/activation_transaction.c @@ -890,49 +890,44 @@ static bool activation_posix_acl_empty(int descriptor) { } #ifdef __linux__ -/* Managed Linux hosts often keep /home a real directory and point the per-user - * entry elsewhere (/home/alice -> /local/home/alice). Trust that entry only - * when root owns it inside a /home nobody else can write, and it resolves to a - * directory owned by root or the current user. Returns NULL when not - * applicable; the O_NOFOLLOW walk then rejects the path as before. */ -static char *activation_linux_home_entry_path(const char *directory) { - static const char home_prefix[] = "/home/"; - size_t prefix_length = sizeof(home_prefix) - 1U; - if (strncmp(directory, home_prefix, prefix_length) != 0) { - return NULL; - } - size_t name_length = strcspn(directory + prefix_length, "/"); - char entry[sizeof(home_prefix) + 256U]; - if (name_length == 0 || name_length >= 256U) { - return NULL; - } - memcpy(entry, directory, prefix_length + name_length); - entry[prefix_length + name_length] = '\0'; +/* Root of the per-user home tree and the account trusted to own it. Root and + * "/home" in production; the test seam substitutes a fixture tree and the test + * account, because the real layout needs root-owned entries to reproduce. */ +static const char *g_activation_home_root = "/home"; +static uid_t g_activation_home_owner = 0; - struct stat home_status; - struct stat entry_status; - struct stat resolved_status; +#ifdef CBM_ENABLE_TEST_SEAMS +void cbm_activation_transaction_set_home_root_for_testing(const char *home_root, + unsigned long owner_uid) { + g_activation_home_root = home_root ? home_root : "/home"; + g_activation_home_owner = home_root ? (uid_t)owner_uid : 0; +} +#endif +#endif + +#if defined(__APPLE__) || defined(__linux__) +/* Map a trusted symlink `link` (already lstat'd) onto its resolved target and + * append `rest`. The link and the target must both belong to `owner`; with + * accept_own_target the target may instead belong to the current account. + * Returns NULL when either check fails or the mapped path does not fit. */ +static char *activation_alias_map(const char *link, const struct stat *link_status, uid_t owner, + bool accept_own_target, const char *rest) { char resolved[4096]; - if (lstat("/home", &home_status) != 0 || !S_ISDIR(home_status.st_mode) || - home_status.st_uid != 0 || (home_status.st_mode & 0022) != 0 || - lstat(entry, &entry_status) != 0 || !S_ISLNK(entry_status.st_mode) || - entry_status.st_uid != 0 || !realpath(entry, resolved) || - lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode) || - (resolved_status.st_uid != 0 && resolved_status.st_uid != geteuid())) { + struct stat resolved_status; + if (link_status->st_uid != owner || !realpath(link, resolved) || + lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode)) { return NULL; } - const char *rest = directory + prefix_length + name_length; - size_t needed = strlen(resolved) + strlen(rest) + 1U; - char *mapped = malloc(needed); - if (!mapped) { + if (resolved_status.st_uid != owner && + !(accept_own_target && resolved_status.st_uid == geteuid())) { return NULL; } - int written = snprintf(mapped, needed, "%s%s", resolved, rest); - if (written <= 0 || (size_t)written >= needed) { - free(mapped); + char mapped[4096]; + int written = snprintf(mapped, sizeof(mapped), "%s%s", resolved, rest); + if (written <= 0 || (size_t)written >= sizeof(mapped)) { return NULL; } - return mapped; + return activation_string_copy(mapped); } #endif @@ -943,7 +938,7 @@ static char *activation_posix_walk_path(const char *directory) { * Resolve only these trusted system aliases; arbitrary user symlinks must * still fail the O_NOFOLLOW walk below. */ #ifdef __linux__ - static const char *const aliases[] = {"/tmp", "/var", "/home"}; + const char *const aliases[] = {"/tmp", "/var", g_activation_home_root}; #else static const char *const aliases[] = {"/tmp", "/var"}; #endif @@ -954,39 +949,48 @@ static char *activation_posix_walk_path(const char *directory) { (directory[alias_length] != '\0' && directory[alias_length] != '/')) { continue; } + uid_t owner = 0; +#ifdef __linux__ + bool is_home = alias == g_activation_home_root; + if (is_home) { + owner = g_activation_home_owner; + } +#endif struct stat alias_status; - char resolved[4096]; if (lstat(alias, &alias_status) != 0) { continue; } - if (!S_ISLNK(alias_status.st_mode)) { + if (S_ISLNK(alias_status.st_mode)) { + return activation_alias_map(alias, &alias_status, owner, false, + directory + alias_length); + } +#ifdef __linux__ + /* Managed Linux hosts often keep /home a real directory and point the + * per-user entry elsewhere (/home/alice -> /local/home/alice, #2306). + * Trust that entry only inside a root-owned /home nobody else can + * write, and only when root owns the entry itself. Unlike the whole- + * alias case above, the target may belong to the current account: + * that is the normal shape of a relocated home, and an account can + * already write its own home, so it gains nothing it did not have. */ + const char *name = directory + alias_length; + size_t name_length = *name == '/' ? strcspn(name + 1, "/") : 0; + if (!is_home || !S_ISDIR(alias_status.st_mode) || alias_status.st_uid != owner || + (alias_status.st_mode & 0022) != 0 || name_length == 0) { continue; } - if (alias_status.st_uid != 0 || !realpath(alias, resolved)) { + char entry[4096]; + size_t entry_length = alias_length + 1U + name_length; + if (entry_length >= sizeof(entry)) { return NULL; } - struct stat resolved_status; - if (lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode) || - resolved_status.st_uid != 0) { - return NULL; + memcpy(entry, directory, entry_length); + entry[entry_length] = '\0'; + struct stat entry_status; + if (lstat(entry, &entry_status) == 0 && S_ISLNK(entry_status.st_mode)) { + return activation_alias_map(entry, &entry_status, owner, true, + directory + entry_length); } - size_t needed = strlen(resolved) + strlen(directory + alias_length) + 1U; - char *mapped = malloc(needed); - if (!mapped) { - return NULL; - } - int written = snprintf(mapped, needed, "%s%s", resolved, directory + alias_length); - if (written <= 0 || (size_t)written >= needed) { - free(mapped); - return NULL; - } - return mapped; - } #endif -#ifdef __linux__ - char *home_entry = activation_linux_home_entry_path(directory); - if (home_entry) { - return home_entry; } #endif return activation_string_copy(directory); diff --git a/src/cli/activation_transaction.h b/src/cli/activation_transaction.h index c0dd89bfc..591f655fb 100644 --- a/src/cli/activation_transaction.h +++ b/src/cli/activation_transaction.h @@ -100,6 +100,11 @@ const char *cbm_activation_transaction_refusal_note(void); #ifdef CBM_ENABLE_TEST_SEAMS void cbm_activation_transaction_note_refusal_for_testing(const char *predicate, unsigned long os_error); +/* Linux only: stand home_root in for /home and owner_uid in for root when + * deciding whether /home or a /home/ entry is a trusted alias. NULL + * restores the production values. Inert elsewhere. */ +void cbm_activation_transaction_set_home_root_for_testing(const char *home_root, + unsigned long owner_uid); #endif #endif /* CBM_ACTIVATION_TRANSACTION_H */ diff --git a/tests/test_activation_transaction.c b/tests/test_activation_transaction.c index b984145f5..28f185f56 100644 --- a/tests/test_activation_transaction.c +++ b/tests/test_activation_transaction.c @@ -764,6 +764,95 @@ TEST(activation_transaction_rejects_symlink_candidate_target_and_parent) { PASS(); } +/* #2306: managed Linux hosts keep /home a real directory and point the + * per-user entry elsewhere (/home/alice -> /local/home/alice). Root owns that + * entry, so it is trusted like the #2175 /home alias; the seam stands a + * fixture tree in for /home and the test account in for root. Every status is + * captured before the seam is reset so a failure cannot leak into the + * following tests. */ +TEST(activation_transaction_follows_trusted_per_user_home_entry) { +#if defined(__linux__) && defined(CBM_ENABLE_TEST_SEAMS) + char root[ACTIVATION_TEST_PATH_CAP]; + char home[ACTIVATION_TEST_PATH_CAP]; + char entry[ACTIVATION_TEST_PATH_CAP]; + char relocated[ACTIVATION_TEST_PATH_CAP]; + char relocated_bin[ACTIVATION_TEST_PATH_CAP]; + char relocated_target[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(root)); + ASSERT_TRUE(activation_test_path(home, root, "home")); + ASSERT_TRUE(activation_test_path(entry, home, "alice")); + ASSERT_TRUE(activation_test_path(relocated, root, "local-alice")); + ASSERT_TRUE(activation_test_path(relocated_bin, relocated, "bin")); + ASSERT_TRUE(activation_test_path(relocated_target, relocated_bin, "cbm")); + ASSERT_TRUE(activation_test_path(target, entry, "bin/cbm")); + ASSERT_TRUE(cbm_mkdir_p(home, 0755)); + ASSERT_EQ(chmod(home, 0755), 0); + ASSERT_TRUE(cbm_mkdir_p(relocated_bin, 0700)); + ASSERT_EQ(symlink(relocated, entry), 0); + unsigned long self = (unsigned long)geteuid(); + + /* Without the seam the entry is an ordinary user symlink: refused. */ + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t untrusted_status = cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction); + bool untrusted_created = transaction != NULL; + (void)cbm_activation_transaction_close(&transaction); + + /* The home root and the entry must belong to the trusted owner. */ + cbm_activation_transaction_set_home_root_for_testing(home, self + 1U); + cbm_activation_transaction_status_t foreign_owner_status = + cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"), + &transaction); + bool foreign_owner_created = transaction != NULL; + (void)cbm_activation_transaction_close(&transaction); + + /* A home root others can write could have its entry swapped: refused. */ + cbm_activation_transaction_set_home_root_for_testing(home, self); + (void)chmod(home, 0775); + cbm_activation_transaction_status_t writable_home_status = + cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"), + &transaction); + bool writable_home_created = transaction != NULL; + (void)cbm_activation_transaction_close(&transaction); + (void)chmod(home, 0755); + + /* Trusted root, trusted entry: staged and committed into the target. */ + cbm_activation_transaction_status_t trusted_status = cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction); + cbm_activation_transaction_status_t commit_status = + transaction ? cbm_activation_transaction_commit(transaction, NULL, NULL) + : CBM_ACTIVATION_TRANSACTION_INVALID_STATE; + cbm_activation_transaction_status_t finalize_status = + transaction ? cbm_activation_transaction_finalize(transaction) + : CBM_ACTIVATION_TRANSACTION_INVALID_STATE; + cbm_activation_transaction_status_t close_status = + cbm_activation_transaction_close(&transaction); + cbm_activation_transaction_set_home_root_for_testing(NULL, 0); + + char contents[ACTIVATION_TEST_CONTENT_CAP] = ""; + bool published = activation_test_read(relocated_target, contents); + int unlink_status = unlink(entry); + int cleanup_status = th_rmtree(root); + + ASSERT_EQ(untrusted_status, CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_FALSE(untrusted_created); + ASSERT_EQ(foreign_owner_status, CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_FALSE(foreign_owner_created); + ASSERT_EQ(writable_home_status, CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_FALSE(writable_home_created); + ASSERT_EQ(trusted_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(commit_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(finalize_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(close_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_TRUE(published); + ASSERT_STR_EQ(contents, "candidate"); + ASSERT_EQ(unlink_status, 0); + ASSERT_EQ(cleanup_status, 0); +#endif + PASS(); +} + TEST(activation_transaction_fails_closed_if_target_directory_is_replaced) { char root[ACTIVATION_TEST_PATH_CAP]; char active[ACTIVATION_TEST_PATH_CAP]; @@ -1089,6 +1178,7 @@ SUITE(activation_transaction) { RUN_TEST(activation_transaction_admits_group_writable_ancestor); RUN_TEST(activation_transaction_rejects_windows_callback_allow_directory_ace); RUN_TEST(activation_transaction_rejects_symlink_candidate_target_and_parent); + RUN_TEST(activation_transaction_follows_trusted_per_user_home_entry); RUN_TEST(activation_transaction_fails_closed_if_target_directory_is_replaced); RUN_TEST(activation_transaction_does_not_replace_target_created_at_publish_boundary); RUN_TEST(activation_transaction_rejects_macos_mutating_extended_acl); From e632d4214bf5ca100ddbd27f303eb013ce9c105e Mon Sep 17 00:00:00 2001 From: Pcristin Date: Wed, 7 Oct 2026 11:00:47 +0000 Subject: [PATCH 07/16] fix(pipeline): initialize frozen coverage state Signed-off-by: Pcristin --- src/pipeline/pipeline.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 60f3c799f..be3a1af70 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -635,6 +635,8 @@ static cbm_pipeline_frozen_status_t frozen_allocate_pipeline( cbm_pipeline_t *p = calloc(1, sizeof(*p)); if (!p) return CBM_PIPELINE_FROZEN_OOM; + atomic_init(&p->unresolved_capture_failed, false); + cbm_mutex_init(&p->unresolved_mutex); owner->pipeline = p; p->frozen = owner; p->repo_path = strdup(in->source_root); From d4273ae901da4834f550d798b5d19d291b4bc29d Mon Sep 17 00:00:00 2001 From: Pcristin Date: Wed, 7 Oct 2026 21:49:42 +0000 Subject: [PATCH 08/16] fix(pipeline): bound unresolved call evidence Signed-off-by: Pcristin --- internal/cbm/cbm.h | 4 - src/foundation/mem_core.c | 2 +- src/foundation/mem_core.h | 1 + src/mcp/mcp.c | 147 ++++++++---- src/pipeline/pass_calls.c | 341 +++++++++++++++++----------- src/pipeline/pass_parallel.c | 162 +++++++------ src/pipeline/pipeline.c | 285 ++++++++++++++++------- src/pipeline/pipeline_incremental.c | 61 +++-- src/pipeline/pipeline_internal.h | 11 +- src/store/store.c | 278 +++++++++++++++++------ src/store/store.h | 14 +- src/store/store_content_digest.c | 38 +++- src/store/store_graph_digest.c | 140 ++++++++---- src/store/store_graph_digest.h | 3 +- tests/test_mcp.c | 136 +++++++++++ tests/test_pipeline.c | 136 +++++++++++ tests/test_store_bulk.c | 256 +++++++++++++++++++++ tests/test_store_graph_digest.c | 90 +++++++- 18 files changed, 1630 insertions(+), 475 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 6d16708ec..5d723c118 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -332,10 +332,6 @@ typedef struct { // (self.compiler.apply_converters()). An object the // class owns, not a parameter: read by the weak-member // guard's unique-name exemption. Default false. - /* Ephemeral coverage evidence, set only while resolving this file. Capture - * it before releasing a spilled result; never retain registry pointers. */ - bool coverage_calls_emitted; - const char *coverage_candidate_qn; } CBMCall; // What an import statement names, when the syntax says so. Only PHP's diff --git a/src/foundation/mem_core.c b/src/foundation/mem_core.c index 121d2696d..426dec44c 100644 --- a/src/foundation/mem_core.c +++ b/src/foundation/mem_core.c @@ -67,7 +67,7 @@ static mem_class_stats_t g_classes[CBM_MEM_CLASS_COUNT]; static const char *const g_class_names[CBM_MEM_CLASS_COUNT] = { "other", "gbuf_node", "gbuf_edge", "gbuf_string", "gbuf_index", "extract", "arena", - "ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array", + "ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array", "resolve", }; const char *cbm_mem_class_name(cbm_mem_class_t cls) { diff --git a/src/foundation/mem_core.h b/src/foundation/mem_core.h index 1514c3c68..6b5cb5cb3 100644 --- a/src/foundation/mem_core.h +++ b/src/foundation/mem_core.h @@ -66,6 +66,7 @@ typedef enum { CBM_MEM_CLASS_STORE, /* SQLite (bound mem methods) + store batches and row buffers */ CBM_MEM_CLASS_HASH_TABLE, /* CBMHashTable buckets/entries not claimed by an owner class */ CBM_MEM_CLASS_DYN_ARRAY, /* CBM_DYN_ARRAY item storage (every cbm_da_* user) */ + CBM_MEM_CLASS_RESOLVE, /* call resolution evidence and coverage capture */ CBM_MEM_CLASS_COUNT } cbm_mem_class_t; diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index efb217e95..9fb41d37c 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -6594,6 +6594,43 @@ static void coverage_add_row_json(yyjson_mut_doc *doc, yyjson_mut_val *array, yyjson_mut_arr_add_val(array, item); } +enum { COVERAGE_EXCLUDED_PREFIX_LENGTH = 11 }; + +enum { + COVERAGE_STATUS_PARSE, + COVERAGE_STATUS_EXCLUDED, + COVERAGE_STATUS_SKIPPED, + COVERAGE_STATUS_UNRESOLVED, + COVERAGE_STATUS_PASSES +}; + +static const char *coverage_row_status(const char *kind, int pass) { + kind = kind ? kind : ""; + if (pass == COVERAGE_STATUS_PARSE) { + if (strcmp(kind, "parse_unusable") == 0) { + return "unusable"; + } + if (strcmp(kind, "parse_partial") == 0) { + return "partial"; + } + } + if (pass == COVERAGE_STATUS_EXCLUDED && + strncmp(kind, "not_indexed", COVERAGE_EXCLUDED_PREFIX_LENGTH) == 0) { + return "excluded"; + } + if (pass == COVERAGE_STATUS_SKIPPED && kind[0] && strcmp(kind, "unresolved_calls") != 0) { + return "skipped"; + } + if (pass == COVERAGE_STATUS_UNRESOLVED && strcmp(kind, "unresolved_calls") == 0) { + return "unresolved_calls"; + } + return NULL; +} + +static bool coverage_unresolved_complete(bool have_meta, const cbm_coverage_meta_t *meta) { + return have_meta && meta->unresolved_calls_complete; +} + static const char *coverage_status(const cbm_coverage_row_t *rows, int count, const char *requested_path, const char *recording_status, bool generation_matches, bool lookup_ok, @@ -6608,29 +6645,14 @@ static const char *coverage_status(const cbm_coverage_row_t *rows, int count, break; } } - for (int pass = 0; pass < 3; pass++) { + for (int pass = 0; pass < COVERAGE_STATUS_PASSES; pass++) { for (int i = 0; i < count; i++) { if (exact && (!rows[i].rel_path || strcmp(rows[i].rel_path, requested_path) != 0)) { continue; } - const char *kind = rows[i].kind ? rows[i].kind : ""; - /* "parse_unusable" must be named here. Without its own case it - * falls through to the catch-all below and reports "skipped", - * which is wrong in the way that matters: the file WAS indexed. */ - if (pass == 0 && strcmp(kind, "parse_unusable") == 0) { - return "unusable"; - } - if (pass == 0 && strcmp(kind, "parse_partial") == 0) { - return "partial"; - } - if (pass == 0 && strcmp(kind, "unresolved_calls") == 0) { - return "unresolved_calls"; - } - if (pass == 1 && strncmp(kind, "not_indexed", 11) == 0) { - return "excluded"; - } - if (pass == 2 && kind[0]) { - return "skipped"; + const char *status = coverage_row_status(rows[i].kind, pass); + if (status) { + return status; } } } @@ -6736,6 +6758,8 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args have_meta ? meta.ignored_files_total : 0); yyjson_mut_obj_add_bool(doc, meta_obj, "hash_records_complete", have_meta && meta.hash_records_complete); + yyjson_mut_obj_add_bool(doc, meta_obj, "unresolved_calls_complete", + coverage_unresolved_complete(have_meta, &meta)); yyjson_mut_obj_add_int(doc, meta_obj, "coverage_version", have_meta ? meta.coverage_version : 0); yyjson_mut_obj_add_bool(doc, meta_obj, "generation_matches", generation_matches); @@ -9265,7 +9289,6 @@ static void trace_coverage_match_site(trace_coverage_match_t *match, const char yyjson_val *site) { const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller")); const char *leaf = yyjson_get_str(yyjson_obj_get(site, "leaf")); - const char *candidate = yyjson_get_str(yyjson_obj_get(site, "candidate")); const cbm_node_t *caller_node = match->callers && caller ? cbm_ht_get(match->callers, caller) : NULL; int line = yyjson_get_int(yyjson_obj_get(site, "line")); @@ -9277,25 +9300,22 @@ static void trace_coverage_match_site(trace_coverage_match_t *match, const char trace_coverage_may_target_project(match, leaf)) { match->unresolved_out = true; } - /* A short-name collision is not evidence that the resolver considered - * this traced target. */ - if (match->targets && candidate && cbm_ht_has(match->targets, candidate)) { - match->unresolved_in = true; - } } -static void trace_coverage_match_row(trace_coverage_match_t *match, const cbm_coverage_row_t *row, - const trace_coverage_scope_t *scope) { +static void trace_coverage_match_row(trace_coverage_match_t *match, const cbm_coverage_row_t *row) { yyjson_doc *detail = yyjson_read(row->detail, strlen(row->detail), 0); yyjson_val *sites = detail ? yyjson_doc_get_root(detail) : NULL; if (!yyjson_is_arr(sites)) { - match->unresolved_out = scope->do_outbound; - match->unresolved_in = scope->do_inbound; + match->unresolved_out = true; } else { size_t idx; size_t max; yyjson_val *site; yyjson_arr_foreach(sites, idx, max, site) { + if (!yyjson_is_obj(site) || yyjson_get_bool(yyjson_obj_get(site, "truncated"))) { + match->unresolved_out = true; + break; + } trace_coverage_match_site(match, row->rel_path, site); } } @@ -9304,24 +9324,61 @@ static void trace_coverage_match_row(trace_coverage_match_t *match, const cbm_co } } -static bool trace_coverage_scan_rows(cbm_store_t *store, const char *project, - trace_coverage_match_t *match, - const trace_coverage_scope_t *scope) { +typedef struct { + trace_coverage_match_t *match; + CBMHashTable *files; + bool ok; +} trace_coverage_query_t; + +static void trace_coverage_query_file(const char *key, void *value, void *userdata) { + (void)key; + const cbm_node_t *caller = value; + trace_coverage_query_t *query = userdata; + const char *path = caller->file_path; + if (!query->ok || query->match->unresolved_out || !path || cbm_ht_has(query->files, path)) { + return; + } + cbm_ht_set(query->files, path, value); + if (!cbm_ht_has(query->files, path)) { + query->ok = false; + return; + } cbm_coverage_row_t *rows = NULL; - int row_count = 0; - if (cbm_store_coverage_get_unresolved_calls(store, project, &rows, &row_count) != - CBM_STORE_OK) { - return false; + int count = 0; + query->ok = cbm_store_coverage_get_unresolved_path(query->match->store, query->match->project, + path, &rows, &count) == CBM_STORE_OK; + for (int i = 0; query->ok && i < count; i++) { + trace_coverage_match_row(query->match, &rows[i]); } - for (int i = 0; i < row_count; i++) { - if ((!scope->do_outbound || match->unresolved_out) && - (!scope->do_inbound || match->unresolved_in)) { - break; + cbm_store_free_coverage(rows, count); +} + +static void trace_coverage_query_candidate(const char *key, void *value, void *userdata) { + (void)value; + trace_coverage_query_t *query = userdata; + if (!query->ok || query->match->unresolved_in) { + return; + } + query->ok = + cbm_store_coverage_has_unresolved_candidate(query->match->store, query->match->project, key, + &query->match->unresolved_in) == CBM_STORE_OK; +} + +static bool trace_coverage_query_evidence(trace_coverage_match_t *match, + const trace_coverage_scope_t *scope) { + trace_coverage_query_t query = {.match = match, .ok = true}; + if (scope->do_outbound) { + query.files = cbm_ht_create(0); + if (!query.files) { + return false; } - trace_coverage_match_row(match, &rows[i], scope); + cbm_ht_foreach(match->callers, trace_coverage_query_file, &query); + cbm_ht_free(query.files); } - cbm_store_free_coverage(rows, row_count); - return true; + if (scope->do_inbound) { + cbm_ht_foreach(match->targets, trace_coverage_query_candidate, &query); + } + return query.ok; } static bool trace_coverage_meta_available(cbm_store_t *store, const char *project) { @@ -9329,7 +9386,7 @@ static bool trace_coverage_meta_available(cbm_store_t *store, const char *projec int rc = cbm_store_coverage_meta_get(store, project, &meta); bool available = rc == CBM_STORE_OK && meta.coverage_version >= CBM_UNRESOLVED_CALL_COVERAGE_VERSION && - meta.recording_status && strcmp(meta.recording_status, "unavailable") != 0; + meta.unresolved_calls_complete; cbm_store_coverage_meta_clear(&meta); return available; } @@ -9347,7 +9404,7 @@ static void trace_call_coverage(cbm_store_t *store, const char *project, bool ok = trace_coverage_meta_available(store, project) && (!scope->do_outbound || match.callers) && (!scope->do_inbound || match.targets) && trace_coverage_build_sets(&match, scope) && - trace_coverage_scan_rows(store, project, &match, scope); + trace_coverage_query_evidence(&match, scope); *unresolved_out = ok ? match.unresolved_out : scope->do_outbound; *unresolved_in = ok ? match.unresolved_in : scope->do_inbound; cbm_ht_free(match.callers); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 3359841b1..e9089b190 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -11,7 +11,13 @@ */ #include "foundation/constants.h" -enum { PC_RING = 4, PC_RING_MASK = 3, PC_SIG_SCAN = 15, PC_REGEX_GRP = 2 }; +enum { + PC_RING = 4, + PC_RING_MASK = 3, + PC_SIG_SCAN = 15, + PC_REGEX_GRP = 2, + PC_SINGLE_CANDIDATE_COUNT = 1 +}; /* Confidence for a service-pattern HTTP/ASYNC edge emitted when registry * resolution is empty (external, unindexed client library) — see #523. */ #define PC_SVC_PATTERN_CONF 0.5 @@ -382,7 +388,7 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje /* Handle a route registration call: create Route node + HANDLES edge. * route_mount is the framework mount of the registering file ("/api" for a * Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */ -static void handle_route_registration(cbm_pipeline_ctx_t *ctx, CBMCall *call, +static bool handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *call, const cbm_gbuf_node_t *source_node, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, const char *route_mount) { @@ -406,9 +412,7 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, CBMCall *call, snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn, esc_fa); - if (cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props) > 0) { - call->coverage_calls_emitted = true; - } + bool emitted = cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props) > 0; if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') { cbm_resolution_t hres = cbm_registry_resolve(ctx->registry, call->second_arg_name, module_qn, imp_keys, imp_vals, imp_count); @@ -424,6 +428,7 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, CBMCall *call, } } } + return emitted; } /* Emit an HTTP/async route edge for a service call. */ @@ -467,7 +472,7 @@ static int64_t create_svc_route_node(cbm_pipeline_ctx_t *ctx, const char *url, c * this, so data_flow mode had no argument expressions to surface for small * (< 50 file) repos that take the sequential path (#514). Mirrors the parallel * path's append_args_json shape so both pipelines agree. */ -static void calls_append_args(char *props, size_t cap, CBMCall *call) { +static void calls_append_args(char *props, size_t cap, const CBMCall *call) { if (!call || call->arg_count <= 0) { return; } @@ -513,8 +518,8 @@ static void calls_append_args(char *props, size_t cap, CBMCall *call) { } } -static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const char *type, - char *props, size_t cap, CBMCall *call) { +static bool calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const char *type, + char *props, size_t cap, const CBMCall *call) { if (call && call->start_line > 0 && strcmp(type, "CALLS") == 0) { size_t len = strlen(props); if (len >= SKIP_ONE && props[len - SKIP_ONE] == '}' && len + CBM_SZ_32 < cap) { @@ -525,13 +530,11 @@ static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const ch if (call && strcmp(type, "CALLS") == 0) { calls_append_args(props, cap, call); } - if (cbm_gbuf_insert_edge(gbuf, src, tgt, type, props) > 0 && call && - strcmp(type, "CALLS") == 0) { - call->coverage_calls_emitted = true; - } + return cbm_gbuf_insert_edge(gbuf, src, tgt, type, props) > 0 && call && + strcmp(type, "CALLS") == 0; } -static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, +static bool emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target, const cbm_resolution_t *res, cbm_svc_kind_t svc, bool suppress_plain_calls) { @@ -552,7 +555,7 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, * target->id into a null dereference (clang-analyzer traced exactly * that), and with no callee node there is nothing to emit anyway. */ if (suppress_plain_calls || !target) { - return; + return false; } char esc_callee[CBM_SZ_256]; cbm_json_escape(esc_callee, sizeof(esc_callee), call->callee_name); @@ -561,8 +564,8 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, "{\"callee\":\"%s\",\"confidence\":%.2f,\"strategy\":\"%s\",\"candidates\":%d}", esc_callee, res->confidence, res->strategy ? res->strategy : "unknown", res->candidate_count); - calls_emit_edge(ctx->gbuf, source->id, target->id, "CALLS", props, sizeof(props), call); - return; + return calls_emit_edge(ctx->gbuf, source->id, target->id, "CALLS", props, sizeof(props), + call); } const char *edge_type = (svc == CBM_SVC_HTTP) ? "HTTP_CALLS" : "ASYNC_CALLS"; const char *method = @@ -593,6 +596,7 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, props[n + 1] = '\0'; } calls_emit_edge(ctx->gbuf, source->id, route_id, edge_type, props, sizeof(props), call); + return false; } /* Classify a resolved call and emit the appropriate edge. */ @@ -600,20 +604,18 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, * match, #592/#606), the route/HTTP/ASYNC/CONFIG service classifications below * still run — only the plain CALLS fall-through is skipped, so a fabricated * project edge is dropped while every service edge stays main-identical. */ -static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, +static bool emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target, const cbm_resolution_t *res, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, bool suppress_plain_calls, const char *route_mount) { cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name); if (svc == CBM_SVC_ROUTE_REG && call->first_string_arg && call->first_string_arg[0] == '/') { - handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count, - route_mount); - return; + return handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, + imp_count, route_mount); } if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) { - emit_http_async_edge(ctx, call, source, target, res, svc, suppress_plain_calls); - return; + return emit_http_async_edge(ctx, call, source, target, res, svc, suppress_plain_calls); } if (svc == CBM_SVC_CONFIG) { char esc_c[CBM_SZ_256]; @@ -625,10 +627,10 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, esc_c, esc_k, res->confidence); calls_emit_edge(ctx->gbuf, source->id, target->id, "CONFIGURES", props, sizeof(props), call); - return; + return false; } if (suppress_plain_calls) { - return; /* weak TS/JS member-call match with an unresolved receiver (#606) */ + return false; /* weak TS/JS member-call match with an unresolved receiver (#606) */ } char esc_c2[CBM_SZ_256]; cbm_json_escape(esc_c2, sizeof(esc_c2), call->callee_name); @@ -637,7 +639,7 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, "{\"callee\":\"%s\",\"confidence\":%.2f,\"strategy\":\"%s\",\"candidates\":%d}", esc_c2, res->confidence, res->strategy ? res->strategy : "unknown", res->candidate_count); - calls_emit_edge(ctx->gbuf, source->id, target->id, "CALLS", props, sizeof(props), call); + return calls_emit_edge(ctx->gbuf, source->id, target->id, "CALLS", props, sizeof(props), call); } /* Find source node for a call: enclosing function or file node. */ @@ -659,11 +661,152 @@ static const cbm_gbuf_node_t *calls_find_source(cbm_pipeline_ctx_t *ctx, const c return src; } +static CBMCallEvidence *calls_evidence_at(CBMCallEvidence *evidence, int index, + CBMCallEvidence *fallback) { + if (evidence) { + return &evidence[index]; + } + return fallback; +} + +static CBMCallEvidence *calls_evidence_new(cbm_pipeline_t *pipeline, const CBMFileResult *result) { + CBMCallEvidence *evidence = + cbm_calloc(CBM_MEM_CLASS_RESOLVE, (size_t)result->calls.count * sizeof(*evidence)); + if (!evidence) { + cbm_pipeline_mark_unresolved_capture_failed(pipeline); + } + return evidence; +} + +/* Report whether a service spelling handled this call, independently of + * whether its classified edge is CALLS and therefore covers the occurrence. */ +static bool calls_emit_service_call(cbm_pipeline_ctx_t *ctx, const CBMCall *call, + const cbm_gbuf_node_t *source_node, cbm_svc_kind_t svc, + const cbm_resolution_t *resolved, CBMCallEvidence *evidence) { + if (svc != CBM_SVC_HTTP && svc != CBM_SVC_ASYNC) { + return false; + } + const char *url = call->first_string_arg; + if (!url || !url[0]) { + return false; + } + bool has_url_or_topic = url[0] == '/' || strstr(url, "://") != NULL || + (svc == CBM_SVC_ASYNC && strlen(url) > PAIR_LEN); + if (!has_url_or_topic) { + return false; + } + cbm_resolution_t fallback = {.qualified_name = call->callee_name, + .confidence = PC_SVC_PATTERN_CONF, + .strategy = "service_pattern", + .candidate_count = 0}; + const cbm_resolution_t *res = resolved ? resolved : &fallback; + evidence->calls_emitted |= emit_http_async_edge(ctx, call, source_node, NULL, res, svc, false); + return true; +} + +/* Route registration counts as handled even when its edge is not CALLS. */ +static bool calls_emit_route_call(cbm_pipeline_ctx_t *ctx, const CBMCall *call, + const cbm_gbuf_node_t *source_node, const char *module_qn, + const char **imp_keys, const char **imp_vals, int imp_count, + const char *route_mount, CBMCallEvidence *evidence) { + if (cbm_service_pattern_route_method(call->callee_name) == NULL || !call->first_string_arg || + call->first_string_arg[0] != '/') { + return false; + } + evidence->calls_emitted |= handle_route_registration( + ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, route_mount); + return true; +} + +/* An external callee can still register a route or address an HTTP/async service. */ +static int calls_emit_unresolved_service(cbm_pipeline_ctx_t *ctx, const CBMCall *call, + const cbm_gbuf_node_t *source_node, const char *module_qn, + const char **imp_keys, const char **imp_vals, + int imp_count, const char *route_mount, + CBMCallEvidence *evidence) { + if (calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, + route_mount, evidence)) { + return SKIP_ONE; + } + cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name); + if (svc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { + svc = CBM_SVC_HTTP; + } + return calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence); +} + +/* Apply the field-call policy after a registry target has been found. */ +static const cbm_gbuf_node_t *calls_field_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call, + const CBMResolvedCallArray *lsp_calls, + CBMLanguage lang, const char *rel, + const cbm_gbuf_node_t *source_node, + const cbm_gbuf_node_t *target_node, + bool arrow_bound, cbm_resolution_t *res) { + switch (cbm_call_onto_field_policy(lang, call->callee_name, target_node->label, + target_node->file_path)) { + case CBM_FIELD_CALL_DROP: + return NULL; + case CBM_FIELD_CALL_BY_OWNER: + /* An arrow call was already bound this way by registry resolution. */ + if (!arrow_bound) { + target_node = cbm_pipeline_c_member_call_resolve( + lsp_calls, NULL, ctx->gbuf, ctx->registry, ctx->project_name, + call->enclosing_func_qn, call->callee_name, cbm_c_member_rule_file(lang, rel), res); + } + if (!target_node || source_node->id == target_node->id) { + return NULL; + } + break; + case CBM_FIELD_CALL_KEEP: + break; + } + return target_node; +} + +static bool calls_emit_lsp_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call, + const CBMResolvedCall *lsp, bool allow_tail, CBMLanguage lang, + const CBMImportArray *imports, const cbm_gbuf_node_t *source_node, + const char *module_qn, const char **imp_keys, + const char **imp_vals, int imp_count, const char *route_mount, + CBMCallEvidence *evidence) { + if (!lsp) { + return false; + } + bool exact_external_target = + call->requires_lsp_resolution && cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn); + const cbm_gbuf_node_t *target_node = + exact_external_target ? cbm_pipeline_lsp_target_node_strict(ctx->gbuf, ctx->project_name, + lsp->callee_qn, allow_tail) + : cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name, + lsp->callee_qn, allow_tail); + if (!target_node || source_node->id == target_node->id) { + return false; + } + cbm_resolution_t res = {0}; + /* Keep the canonical graph QN, including project prefixes added by fallback. */ + res.qualified_name = target_node->qualified_name; + res.confidence = lsp->confidence; + res.strategy = lsp->strategy; + res.candidate_count = PC_SINGLE_CANDIDATE_COUNT; + evidence->candidate_qn = target_node->qualified_name; + /* Synthetic builtins can still denote a spawn; keep the parallel pass policy. */ + cbm_pipeline_spawn_t spawn; + if (cbm_pipeline_spawn_site(ctx->gbuf, lang, call, imports, &res, &spawn)) { + cbm_pipeline_emit_spawn(ctx->gbuf, source_node, call, &spawn); + return true; + } + evidence->calls_emitted |= + emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, + imp_vals, imp_count, false, route_mount); + return true; +} + /* Resolve one call and emit the appropriate edge. Returns 1 if resolved, 0 if not. */ -static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBMFileResult *result, - const char *rel, const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count, CBMLanguage lang, - const CBMImportArray *imports, const char *route_mount) { +static int resolve_single_call(cbm_pipeline_ctx_t *ctx, const CBMCall *call, + const CBMFileResult *result, const char *rel, const char *module_qn, + const char **imp_keys, const char **imp_vals, int imp_count, + CBMLanguage lang, const CBMImportArray *imports, + const char *route_mount, CBMCallEvidence *evidence) { const CBMResolvedCallArray *lsp_calls = &result->resolved_calls; const cbm_gbuf_node_t *source_node = calls_find_source(ctx, rel, call->enclosing_func_qn); if (!source_node) { @@ -675,36 +818,9 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang); const CBMResolvedCall *lsp = cbm_pipeline_find_lsp_resolution_in_graph( lsp_calls, call, allow_tail, ctx->gbuf, ctx->project_name); - if (lsp) { - bool exact_external_target = call->requires_lsp_resolution && - cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn); - const cbm_gbuf_node_t *target_node = - exact_external_target ? cbm_pipeline_lsp_target_node_strict( - ctx->gbuf, ctx->project_name, lsp->callee_qn, allow_tail) - : cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name, - lsp->callee_qn, allow_tail); - if (target_node && source_node->id != target_node->id) { - cbm_resolution_t res = {0}; - /* Use the gbuf node's QN so downstream edge props show the canonical - * project-qualified form even when fallback prefixed the project. */ - res.qualified_name = target_node->qualified_name; - res.confidence = lsp->confidence; - res.strategy = lsp->strategy; - res.candidate_count = CBM_ALLOC_ONE; - call->coverage_candidate_qn = target_node->qualified_name; - /* An LSP answer on a synthetic builtin (``) is - * not project code: a spawn spelling still spawns. The parallel - * pass keeps the LSP answer in `res` and asks the same question. - * MUST match pass_parallel.c. */ - cbm_pipeline_spawn_t spawn; - if (cbm_pipeline_spawn_site(ctx->gbuf, lang, call, imports, &res, &spawn)) { - cbm_pipeline_emit_spawn(ctx->gbuf, source_node, call, &spawn); - return SKIP_ONE; - } - emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, - imp_vals, imp_count, false, route_mount); - return SKIP_ONE; - } + if (calls_emit_lsp_target(ctx, call, lsp, allow_tail, lang, imports, source_node, module_qn, + imp_keys, imp_vals, imp_count, route_mount, evidence)) { + return SKIP_ONE; } /* Synthetic semantic candidates (currently implicit C++ operators) are @@ -740,23 +856,13 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM .confidence = PC_SVC_PATTERN_CONF, .strategy = "http_client_instance", .candidate_count = 0}; - emit_http_async_edge(ctx, &routed, source_node, NULL, &svc_res, CBM_SVC_HTTP, false); + evidence->calls_emitted |= + emit_http_async_edge(ctx, &routed, source_node, NULL, &svc_res, CBM_SVC_HTTP, false); return SKIP_ONE; } - cbm_svc_kind_t csvc = cbm_service_pattern_match(call->callee_name); - if (csvc == CBM_SVC_HTTP || csvc == CBM_SVC_ASYNC) { - const char *cu = call->first_string_arg; - bool chas_url = cu && cu[0] != '\0' && - (cu[0] == '/' || strstr(cu, "://") != NULL || - (csvc == CBM_SVC_ASYNC && strlen(cu) > PAIR_LEN)); - if (chas_url) { - cbm_resolution_t svc_res = {.qualified_name = call->callee_name, - .confidence = PC_SVC_PATTERN_CONF, - .strategy = "service_pattern", - .candidate_count = 0}; - emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, csvc, false); - return SKIP_ONE; - } + if (calls_emit_service_call(ctx, call, source_node, + cbm_service_pattern_match(call->callee_name), NULL, evidence)) { + return SKIP_ONE; } /* #2053: a Rust call the LSP placed on an EXTERNAL symbol (std's @@ -819,31 +925,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM * apps. Classify by callee suffix + path-shaped first arg, exactly * like the parallel path's callee_suffix fallback; without this the * sequential path minted zero Route nodes for such files. */ - if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg && - call->first_string_arg[0] == '/') { - handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals, - imp_count, route_mount); - return SKIP_ONE; - } - cbm_svc_kind_t esvc = cbm_service_pattern_match(call->callee_name); - if (esvc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { - esvc = CBM_SVC_HTTP; - } - if (esvc == CBM_SVC_HTTP || esvc == CBM_SVC_ASYNC) { - const char *u = call->first_string_arg; - bool has_url_or_topic = u && u[0] != '\0' && - (u[0] == '/' || strstr(u, "://") != NULL || - (esvc == CBM_SVC_ASYNC && strlen(u) > PAIR_LEN)); - if (has_url_or_topic) { - cbm_resolution_t svc_res = {.qualified_name = call->callee_name, - .confidence = PC_SVC_PATTERN_CONF, - .strategy = "service_pattern", - .candidate_count = 0}; - emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, esvc, false); - return SKIP_ONE; - } - } - return 0; + return calls_emit_unresolved_service(ctx, call, source_node, module_qn, imp_keys, imp_vals, + imp_count, route_mount, evidence); } /* Perl call-graph noise guard (#476). Perl has no LSP resolver, so the @@ -920,16 +1003,9 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM * the missing target must NOT drop the call — otherwise no HTTP_CALLS edge * is written and cross-repo matching finds nothing (#523). Emit directly * when the call carries a URL/topic first argument. */ - cbm_svc_kind_t svc = cbm_service_pattern_match(res.qualified_name); - if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) { - const char *u = call->first_string_arg; - bool has_url_or_topic = u && u[0] != '\0' && - (u[0] == '/' || strstr(u, "://") != NULL || - (svc == CBM_SVC_ASYNC && strlen(u) > PAIR_LEN)); - if (has_url_or_topic) { - emit_http_async_edge(ctx, call, source_node, NULL, &res, svc, false); - return SKIP_ONE; - } + if (calls_emit_service_call(ctx, call, source_node, + cbm_service_pattern_match(res.qualified_name), &res, evidence)) { + return SKIP_ONE; } const cbm_gbuf_node_t *target_node = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name); @@ -939,24 +1015,10 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM /* A call that resolved onto a struct Field: refused across languages and * for a bare C call; a C/C++ member call takes the Field its object's type * names instead of the one the member name happened to find. */ - switch (cbm_call_onto_field_policy(lang, call->callee_name, target_node->label, - target_node->file_path)) { - case CBM_FIELD_CALL_DROP: + target_node = calls_field_target(ctx, call, lsp_calls, lang, rel, source_node, target_node, + arrow_bound, &res); + if (!target_node) { return 0; - case CBM_FIELD_CALL_BY_OWNER: - /* An arrow call was already bound this way above. */ - if (!arrow_bound) { - target_node = cbm_pipeline_c_member_call_resolve( - lsp_calls, NULL, ctx->gbuf, ctx->registry, ctx->project_name, - call->enclosing_func_qn, call->callee_name, cbm_c_member_rule_file(lang, rel), - &res); - } - if (!target_node || source_node->id == target_node->id) { - return 0; - } - break; - case CBM_FIELD_CALL_KEEP: - break; } /* #725: suffix_match is language-agnostic and will attach a Python * Store.commit() call to a JS function named commit (or a Bash main @@ -969,17 +1031,13 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM * unresolved-callee fallback above mints, and nothing else. Dropping * the whole call lost every GET registration in a mixed-language repo * while POST (no `post` to collide with) survived. */ - if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg && - call->first_string_arg[0] == '/') { - handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals, - imp_count, route_mount); - return SKIP_ONE; - } - return 0; + return calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, + imp_count, route_mount, evidence); } - call->coverage_candidate_qn = target_node->qualified_name; - emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, - imp_count, drop_plain_call, route_mount); + evidence->candidate_qn = target_node->qualified_name; + evidence->calls_emitted |= + emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, + imp_vals, imp_count, drop_plain_call, route_mount); return SKIP_ONE; } @@ -1130,12 +1188,15 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file } if (result->calls.count == 0) { + cbm_pipeline_record_unresolved_calls(ctx->pipeline, rel, result, NULL); if (result_owned) { cbm_free_result(result); } continue; } + CBMCallEvidence *evidence = calls_evidence_new(ctx->pipeline, result); + /* Build import map for this file */ const char **imp_keys = NULL; const char **imp_vals = NULL; @@ -1154,19 +1215,23 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file /* Resolve each call */ for (int c = 0; c < result->calls.count; c++) { - CBMCall *call = &result->calls.items[c]; + const CBMCall *call = &result->calls.items[c]; if (!call->callee_name) { continue; } + CBMCallEvidence fallback = {0}; total_calls++; if (resolve_single_call(ctx, call, result, rel, module_qn, imp_keys, imp_vals, - imp_count, files[i].language, &result->imports, route_mount)) { + imp_count, files[i].language, &result->imports, route_mount, + calls_evidence_at(evidence, c, &fallback))) { resolved++; } else { unresolved++; } } + cbm_pipeline_record_unresolved_calls(ctx->pipeline, rel, result, evidence); + cbm_free(CBM_MEM_CLASS_RESOLVE, evidence); free(module_qn); free_import_map(imp_keys, imp_vals, imp_count); if (result_owned) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index b34db716c..05cd8e4cd 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2109,7 +2109,7 @@ static int format_call_arg(char *buf, size_t bufsize, const CBMCallArg *a, const return snprintf(buf, bufsize, "{\"i\":%d,\"e\":\"%s\"}", a->index, esc_e); } -static size_t append_args_json(char *buf, size_t bufsize, size_t pos, CBMCall *call) { +static size_t append_args_json(char *buf, size_t bufsize, size_t pos, const CBMCall *call) { if (call->arg_count == 0 || pos >= bufsize - PP_ARGS_MARGIN) { return pos; } @@ -2165,7 +2165,7 @@ static bool is_route_path_shaped(const char *val) { return val && val[0] == '/' && !cbm_service_pattern_is_comment_text(val); } -static const char *find_route_path_in_args(CBMCall *call, const char **out_handler) { +static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler) { *out_handler = NULL; /* 1. First string arg starting with / */ if (is_route_path_shaped(call->first_string_arg)) { @@ -2204,8 +2204,8 @@ static const char *find_route_path_in_args(CBMCall *call, const char **out_handl } /* Build props JSON, append args, close brace, emit edge. */ -static void finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id, - const char *edge_type, char *props, int n, CBMCall *call) { +static bool finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id, + const char *edge_type, char *props, int n, const CBMCall *call) { if (n > 0 && (size_t)n < CBM_SZ_2K - PP_ESC_SPACE) { size_t pos = append_args_json(props, CBM_SZ_2K, (size_t)n, call); if (call->start_line > 0 && strcmp(edge_type, "CALLS") == 0 && @@ -2220,10 +2220,8 @@ static void finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id, props[pos + SKIP_ONE] = '\0'; } } - if (cbm_gbuf_insert_edge(gbuf, src_id, tgt_id, edge_type, props) > 0 && - strcmp(edge_type, "CALLS") == 0) { - call->coverage_calls_emitted = true; - } + return cbm_gbuf_insert_edge(gbuf, src_id, tgt_id, edge_type, props) > 0 && + strcmp(edge_type, "CALLS") == 0; } /* Build Route node QN and properties for HTTP/async service edges. */ @@ -2253,7 +2251,7 @@ static int64_t build_service_route(cbm_gbuf_t *gbuf, const char *arg, const char /* Emit HTTP_CALLS or ASYNC_CALLS edge via Route node. */ static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - CBMCall *call, const cbm_resolution_t *res, + const CBMCall *call, const cbm_resolution_t *res, cbm_svc_kind_t svc, const char *arg) { const char *edge_type = (svc == CBM_SVC_HTTP) ? "HTTP_CALLS" : "ASYNC_CALLS"; const char *method = @@ -2280,7 +2278,7 @@ static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t /* Emit CONFIGURES edge. */ static void emit_config_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const cbm_gbuf_node_t *target, CBMCall *call, + const cbm_gbuf_node_t *target, const CBMCall *call, const cbm_resolution_t *res, const char *arg) { /* emit_service_edge may be reached with target==NULL on the HTTP/ASYNC * external-client bypass (#523); a CONFIGURES edge needs a real target, so @@ -2299,13 +2297,13 @@ static void emit_config_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, } /* Emit normal CALLS edge. */ -static void emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const cbm_gbuf_node_t *target, CBMCall *call, +static bool emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, + const cbm_gbuf_node_t *target, const CBMCall *call, const cbm_resolution_t *res) { /* A CALLS edge needs a real target; the HTTP/ASYNC external-client bypass * (#523) can reach emit_service_edge with target==NULL, so guard the deref. */ if (!target) { - return; + return false; } char esc_c[CBM_SZ_256]; cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name); @@ -2314,18 +2312,19 @@ static void emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sour "{\"callee\":\"%s\",\"confidence\":%.2f,\"strategy\":\"%s\",\"candidates\":%d", esc_c, res->confidence, res->strategy ? res->strategy : "unknown", res->candidate_count); - finalize_and_emit(gbuf, source->id, target->id, "CALLS", props, n, call); + return finalize_and_emit(gbuf, source->id, target->id, "CALLS", props, n, call); } /* Classify a resolved call by library identity and emit the appropriate edge. */ /* Create Route node + CALLS + HANDLES edges for a route registration call. * route_mount is the framework mount of the registering file ("/api" for a * Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */ -static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, - const char *route_path, const char *handler_ref, - const char *module_qn, const cbm_registry_t *registry, - const cbm_gbuf_t *main_gbuf, const char **ik, const char **iv, - int ic, const char *route_mount) { +static bool emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, + const CBMCall *call, const char *route_path, + const char *handler_ref, const char *module_qn, + const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, + const char **ik, const char **iv, int ic, + const char *route_mount) { const char *method = cbm_service_pattern_route_method(call->callee_name); char mounted[CBM_SZ_256]; route_path = cbm_laravel_mount_route(route_mount, route_path, mounted, sizeof(mounted)); @@ -2344,9 +2343,7 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn, esc_rp); - if (cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props) > 0) { - call->coverage_calls_emitted = true; - } + bool emitted = cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props) > 0; if (handler_ref && handler_ref[0] != '\0') { cbm_resolution_t hres = cbm_registry_resolve(registry, handler_ref, module_qn, ik, iv, ic); if (hres.qualified_name && hres.qualified_name[0] != '\0') { @@ -2361,6 +2358,7 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou } } } + return emitted; } /* Reject regex metacharacters, spaces, double-slashes in URL candidates. */ @@ -2414,7 +2412,8 @@ static bool normalize_url_arg(const char *url, char *norm, int norm_sz) { } /* Detect API paths in call arguments and create HTTP_CALLS edges. */ -static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call) { +static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, + const CBMCall *call) { for (int ai = 0; ai < call->arg_count; ai++) { const CBMCallArg *ca = &call->args[ai]; /* A slash-prefixed raw expression is not a URL string. In JS/TS this @@ -2519,7 +2518,7 @@ bool extract_grpc_service_method(const char *callee, char *service, size_t srv_s } /* Emit GRPC_CALLS edge via gRPC Route node. */ -static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, +static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, const cbm_resolution_t *res) { char service[CBM_SZ_256]; char method[CBM_SZ_256]; @@ -2596,7 +2595,7 @@ static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, c } /* Emit TRPC_CALLS edge. Extract procedure path from callee chain. */ -static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, +static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, const cbm_resolution_t *res) { /* tRPC calls: trpc.user.getById.query() → extract "user.getById" */ const char *callee = call->callee_name; @@ -2638,8 +2637,8 @@ static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMC * and the HTTP/ASYNC/gRPC/GraphQL/tRPC/CONFIG/route branches are unaffected, so * a verb-suffix HTTP client (api.patch('/x')), broker, or route registration * keeps its edge; only the fabricated project CALLS edge is dropped. */ -static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const cbm_gbuf_node_t *target, CBMCall *call, +static bool emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, + const cbm_gbuf_node_t *target, const CBMCall *call, const cbm_resolution_t *res, const char *module_qn, const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, const char **imp_keys, const char **imp_vals, int imp_count, @@ -2669,10 +2668,9 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const char *handler_ref = NULL; const char *route_path = find_route_path_in_args(call, &handler_ref); if (route_path) { - emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, - registry, main_gbuf, imp_keys, imp_vals, imp_count, - route_mount); - return; + return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, + registry, main_gbuf, imp_keys, imp_vals, imp_count, + route_mount); } /* No path found — fall through to normal CALLS edge */ } @@ -2682,6 +2680,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, /* Set when this call already has its HTTP_CALLS edge from the service * patterns (typed with the verb its callee names). */ bool http_edge_emitted = false; + bool calls_emitted = false; if ((svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) && (has_url || has_topic)) { emit_http_async_service_edge(gbuf, source, call, res, svc, arg); @@ -2695,7 +2694,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, } else if (svc == CBM_SVC_CONFIG) { emit_config_edge(gbuf, source, target, call, res, arg); } else if (!suppress_plain_calls) { - emit_normal_calls_edge(gbuf, source, target, call, res); + calls_emitted = emit_normal_calls_edge(gbuf, source, target, call, res); } /* The arg-URL heuristic is for calls no service pattern knows (a local @@ -2705,6 +2704,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, if (!http_edge_emitted) { detect_url_in_args(gbuf, source, call); } + return calls_emitted; } /* The #725 guard refuses a suffix_match binding across a language boundary, @@ -2717,24 +2717,26 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, * CALLS edge to the refused target and no URL-argument scan. Dropping the * whole call lost every GET registration in a mixed-language repo while POST * (no `post` to collide with) survived. Mirrors pass_calls.c. */ -static void emit_xlang_refused_route(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, - const char *module_qn, const cbm_registry_t *registry, - const cbm_gbuf_t *main_gbuf, const char **imp_keys, - const char **imp_vals, int imp_count, +static bool emit_xlang_refused_route(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, + const CBMCall *call, const char *module_qn, + const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, + const char **imp_keys, const char **imp_vals, int imp_count, const char *route_mount) { if (cbm_service_pattern_route_method(call->callee_name) == NULL) { - return; + return false; } cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name); if (svc != CBM_SVC_NONE && svc != CBM_SVC_ROUTE_REG) { - return; + return false; } const char *handler_ref = NULL; const char *route_path = find_route_path_in_args(call, &handler_ref); if (route_path) { - emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, registry, - main_gbuf, imp_keys, imp_vals, imp_count, route_mount); + return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, + registry, main_gbuf, imp_keys, imp_vals, imp_count, + route_mount); } + return false; } /* Find the source node for an edge: enclosing function or file node. */ @@ -2937,7 +2939,7 @@ static bool lsp_idx_insert_leaf(CBMHashTable *index, CBMResolvedCall *candidate, return inserted; } -static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, CBMCall *call, +static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, const CBMCall *call, bool exact_site, bool *key_built, bool *ambiguous) { if (key_built) { *key_built = false; @@ -2981,10 +2983,31 @@ static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, CBMCall : NULL; } +static CBMCallEvidence *resolve_evidence_at(CBMCallEvidence *evidence, int index, + CBMCallEvidence *fallback) { + if (evidence) { + return &evidence[index]; + } + return fallback; +} + +static CBMCallEvidence *resolve_evidence_new(const resolve_ctx_t *rc, const CBMFileResult *result) { + if (result->calls.count == 0) { + return NULL; + } + CBMCallEvidence *evidence = + cbm_calloc(CBM_MEM_CLASS_RESOLVE, (size_t)result->calls.count * sizeof(*evidence)); + if (!evidence) { + cbm_pipeline_mark_unresolved_capture_failed(rc->pctx ? rc->pctx->pipeline : NULL); + } + return evidence; +} + /* Resolve calls for one file and emit CALLS/HTTP_CALLS/ASYNC_CALLS edges. */ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result, const char *rel, const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count, CBMLanguage lang) { + const char **imp_vals, int imp_count, CBMLanguage lang, + CBMCallEvidence *evidence) { /* Framework mount of this file's routes (Laravel 11+ withRouting, #1146). */ char route_mount[CBM_SZ_128]; cbm_laravel_file_route_mount(rc->repo_path, rel, lang, result, route_mount, @@ -3060,7 +3083,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB bool field_index_built = false; bool field_index_ready = false; for (int c = 0; c < result->calls.count; c++) { - CBMCall *call = &result->calls.items[c]; + const CBMCall *call = &result->calls.items[c]; + CBMCallEvidence fallback = {0}; + CBMCallEvidence *site_evidence = resolve_evidence_at(evidence, c, &fallback); if (!call->callee_name) { continue; } @@ -3284,9 +3309,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB cbm_resolution_t svc_res = {.qualified_name = call->callee_name, .confidence = PP_HALF_CONF, .strategy = "service_pattern"}; - emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &svc_res, - module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, - imp_count, false, route_mount); + site_evidence->calls_emitted |= emit_service_edge( + ws->local_edge_buf, source_node, source_node, call, &svc_res, module_qn, + rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, false, route_mount); continue; } } @@ -3311,9 +3336,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * route path (`cache.get(key)`) would be a self-loop; that holds * for #2053's LSP-external Rust calls too. Suppress it always; * route/HTTP/service classification is unchanged. */ - emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res, - module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, - imp_count, true, route_mount); + site_evidence->calls_emitted |= emit_service_edge( + ws->local_edge_buf, source_node, source_node, call, &fake_res, module_qn, + rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, true, route_mount); } else if (cbm_service_pattern_is_global_fetch(call->callee_name)) { /* Native `fetch()` (#856): only the global API once resolution * has failed to find a local/imported `fetch`. Call the low-level @@ -3373,8 +3398,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB /* #725: same guard as pass_calls.c — do not emit a suffix_match * CALLS edge across a language boundary. A route registration * behind the refused binding still gets its Route. */ - emit_xlang_refused_route(ws->local_edge_buf, source_node, call, module_qn, rc->registry, - rc->main_gbuf, imp_keys, imp_vals, imp_count, route_mount); + site_evidence->calls_emitted |= emit_xlang_refused_route( + ws->local_edge_buf, source_node, call, module_qn, rc->registry, rc->main_gbuf, + imp_keys, imp_vals, imp_count, route_mount); continue; } if (!target_node || source_node->id == target_node->id) { @@ -3390,19 +3416,19 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB (u[0] == '/' || strstr(u, "://") != NULL || (psvc == CBM_SVC_ASYNC && strlen(u) > PP_ESC_SPACE)); if (url_or_topic) { - emit_service_edge(ws->local_edge_buf, source_node, NULL, call, &res, module_qn, - rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, - false, route_mount); + site_evidence->calls_emitted |= emit_service_edge( + ws->local_edge_buf, source_node, NULL, call, &res, module_qn, rc->registry, + rc->main_gbuf, imp_keys, imp_vals, imp_count, false, route_mount); ws->calls_resolved++; } } continue; } - call->coverage_candidate_qn = target_node->qualified_name; + site_evidence->candidate_qn = target_node->qualified_name; _rc_t0 = extract_now_ns(); - emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn, - rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, - drop_plain_call, route_mount); + site_evidence->calls_emitted |= emit_service_edge( + ws->local_edge_buf, source_node, target_node, call, &res, module_qn, rc->registry, + rc->main_gbuf, imp_keys, imp_vals, imp_count, drop_plain_call, route_mount); atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0, memory_order_relaxed); ws->calls_resolved++; @@ -3896,8 +3922,9 @@ static CBMFileResult *resolve_load_result(resolve_ctx_t *rc, resolve_worker_stat } static void resolve_capture_coverage(const resolve_ctx_t *rc, const char *rel, - const CBMFileResult *result) { - cbm_pipeline_record_unresolved_calls(rc->pctx ? rc->pctx->pipeline : NULL, rel, result); + const CBMFileResult *result, const CBMCallEvidence *evidence) { + cbm_pipeline_record_unresolved_calls(rc->pctx ? rc->pctx->pipeline : NULL, rel, result, + evidence); } /* A C member access binds only through a field-owner row. The per-file walk @@ -3995,6 +4022,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { CBMLanguage lang = rc->files[file_idx].language; const char *rel = rc->files[file_idx].rel_path; + CBMCallEvidence *evidence = NULL; /* Skip cross-LSP for machine-generated files — they're huge (10k- * 70k lines for k8s protobuf/openapi), have low semantic value for @@ -4052,7 +4080,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { if (result->calls.count == 0 && result->usages.count == 0 && result->throws.count == 0 && result->rw.count == 0 && result->defs.count == 0 && result->impl_traits.count == 0 && !cross_lsp_eligible) { - resolve_capture_coverage(rc, rel, result); + resolve_capture_coverage(rc, rel, result, NULL); continue; } @@ -4181,9 +4209,12 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { /* Per-sub-phase wall-clock so we can attribute the dominant cost. */ uint64_t _ph_t0; + /* Cross-LSP may append synthetic calls; their positions are now final. */ + evidence = resolve_evidence_new(rc, result); /* ── CALLS resolution ──────────────────────────────────── */ _ph_t0 = extract_now_ns(); - resolve_file_calls(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang); + resolve_file_calls(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang, + evidence); atomic_fetch_add_explicit(&rc->time_ns_calls, extract_now_ns() - _ph_t0, memory_order_relaxed); @@ -4211,9 +4242,10 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { memory_order_relaxed); resolve_file_cleanup: - /* The cross-file additions and actual emitted-site evidence live in - * this result, including when it was loaded from the spill store. */ - resolve_capture_coverage(rc, rel, result); + /* Capture the resolved file and side evidence before releasing either, + * including files loaded from the spill store. */ + resolve_capture_coverage(rc, rel, result, evidence); + cbm_free(CBM_MEM_CLASS_RESOLVE, evidence); cbm_registry_reach_cache_end(); cbm_registry_import_map_cache_end(); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index be3a1af70..34a579e13 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -927,28 +927,44 @@ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *row static void *unresolved_json_alloc(void *ctx, size_t size) { (void)ctx; - return cbm_alloc(CBM_MEM_CLASS_DUMP, size); + return cbm_alloc(CBM_MEM_CLASS_RESOLVE, size); } static void *unresolved_json_realloc(void *ctx, void *ptr, size_t old_size, size_t size) { (void)ctx; (void)old_size; - return cbm_realloc(CBM_MEM_CLASS_DUMP, ptr, size); + return cbm_realloc(CBM_MEM_CLASS_RESOLVE, ptr, size); } static void unresolved_json_free(void *ctx, void *ptr) { (void)ctx; - cbm_free(CBM_MEM_CLASS_DUMP, ptr); + cbm_free(CBM_MEM_CLASS_RESOLVE, ptr); } -enum { UNRESOLVED_KEY_OVERHEAD = 128, UNRESOLVED_INITIAL_ROWS = 64, UNRESOLVED_ROW_GROWTH = 2 }; +enum { + UNRESOLVED_KEY_OVERHEAD = 128, + UNRESOLVED_INITIAL_ROWS = 64, + UNRESOLVED_ROW_GROWTH = 2, + UNRESOLVED_MAX_SITES = 1000, + UNRESOLVED_MAX_JSON_BYTES = 128 * 1024, + UNRESOLVED_SITE_OVERHEAD = 256, + UNRESOLVED_TRUNCATION_RESERVE = 32, + UNRESOLVED_JSON_ARRAY_BYTES = 2, + UNRESOLVED_JSON_RAW_CHAR_BYTES = 1, + UNRESOLVED_JSON_ESCAPE_BYTES = 2, + UNRESOLVED_JSON_CONTROL_ESCAPE_BYTES = 6, + UNRESOLVED_JSON_CONTROL_BOUNDARY = 0x20, + UNRESOLVED_JSON_OVERSIZE = UNRESOLVED_MAX_JSON_BYTES + 1, + UNRESOLVED_NO_CARRIER = -1, + UNRESOLVED_AMBIGUOUS_CARRIER = -2 +}; static char *unresolved_site_key(const char *caller, const char *leaf, uint32_t start, uint32_t end, CBMSourceOrigin origin) { size_t cn = strlen(caller); size_t ln = strlen(leaf); size_t cap = cn + ln + UNRESOLVED_KEY_OVERHEAD; - char *key = cbm_alloc(CBM_MEM_CLASS_DUMP, cap); + char *key = cbm_alloc(CBM_MEM_CLASS_RESOLVE, cap); if (key) { (void)snprintf(key, cap, "%zu:%s|%zu:%s|%u:%u:%u", cn, caller, ln, leaf, start, end, (unsigned)origin); @@ -957,7 +973,7 @@ static char *unresolved_site_key(const char *caller, const char *leaf, uint32_t } typedef struct { - const CBMCall *call; + int call_index; } unresolved_carrier_t; typedef struct { @@ -965,18 +981,24 @@ typedef struct { yyjson_mut_val *sites; CBMHashTable *carriers; CBMHashTable *seen; + const CBMFileResult *result; + const CBMCallEvidence *evidence; + const CBMResolvedCall *selected[UNRESOLVED_MAX_SITES]; + int selected_count; + size_t json_bytes; + bool truncated; } unresolved_capture_t; static void unresolved_carrier_free(const char *key, void *value, void *ctx) { (void)ctx; - cbm_free(CBM_MEM_CLASS_DUMP, (void *)key); - cbm_free(CBM_MEM_CLASS_DUMP, value); + cbm_free(CBM_MEM_CLASS_RESOLVE, (void *)key); + cbm_free(CBM_MEM_CLASS_RESOLVE, value); } static void unresolved_key_free(const char *key, void *value, void *ctx) { (void)value; (void)ctx; - cbm_free(CBM_MEM_CLASS_DUMP, (void *)key); + cbm_free(CBM_MEM_CLASS_RESOLVE, (void *)key); } static void unresolved_capture_free(unresolved_capture_t *capture) { @@ -1007,52 +1029,129 @@ static bool unresolved_has_missing_calls(const CBMFileResult *result) { return false; } -static bool unresolved_add_carrier(CBMHashTable *carriers, const CBMCall *call) { - if (!call->callee_name || call->site_end_byte <= call->site_start_byte) { - return true; +static size_t unresolved_json_char_bytes(unsigned char ch) { + if (ch < UNRESOLVED_JSON_CONTROL_BOUNDARY) { + return UNRESOLVED_JSON_CONTROL_ESCAPE_BYTES; } - char *key = - unresolved_site_key("", cbm_lsp_bare_segment(call->callee_name), call->site_start_byte, - call->site_end_byte, call->source_origin); - if (!key) { - return false; + if (ch == '"' || ch == '\\') { + return UNRESOLVED_JSON_ESCAPE_BYTES; } - if (cbm_ht_has(carriers, key)) { - /* Ambiguous extractor occurrences must never erase a diagnostic. */ - unresolved_carrier_t *carrier = cbm_ht_get(carriers, key); - carrier->call = NULL; - cbm_free(CBM_MEM_CLASS_DUMP, key); - return true; - } - unresolved_carrier_t *carrier = cbm_alloc(CBM_MEM_CLASS_DUMP, sizeof(*carrier)); - if (!carrier) { - cbm_free(CBM_MEM_CLASS_DUMP, key); - return false; - } - carrier->call = call; - cbm_ht_set(carriers, key, carrier); - if (!cbm_ht_has(carriers, key)) { - cbm_free(CBM_MEM_CLASS_DUMP, key); - cbm_free(CBM_MEM_CLASS_DUMP, carrier); - return false; - } - return true; + return UNRESOLVED_JSON_RAW_CHAR_BYTES; } -static bool unresolved_build_carriers(CBMHashTable *carriers, const CBMFileResult *result) { - /* Caller names in the TS LSP walk can denote an outer factory. The - * extractor's exact occurrence is the authoritative enclosing function. - * Index by span AND leaf: two receiver calls on one line remain distinct. */ - for (int i = 0; i < result->calls.count; i++) { - if (!unresolved_add_carrier(carriers, &result->calls.items[i])) { +/* Stop at the budget even for one enormous string. Reserve six bytes for each + * escaped control character; quotes and backslashes use two bytes in JSON. */ +static size_t unresolved_string_json_bytes(const char *text) { + size_t bytes = 0; + for (const unsigned char *p = (const unsigned char *)text; p && *p; p++) { + size_t width = unresolved_json_char_bytes(*p); + if (bytes > UNRESOLVED_MAX_JSON_BYTES - width) { + return UNRESOLVED_JSON_OVERSIZE; + } + bytes += width; + } + return bytes; +} + +static size_t unresolved_site_json_bytes(const CBMResolvedCall *rc, const char *caller, + const char *leaf, const char *candidate) { + const char *fields[] = {caller, leaf, rc->reason ? rc->reason : "unresolved", candidate}; + size_t bytes = UNRESOLVED_SITE_OVERHEAD; + for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) { + size_t field_bytes = unresolved_string_json_bytes(fields[i]); + if (field_bytes > UNRESOLVED_MAX_JSON_BYTES - bytes) { + return UNRESOLVED_JSON_OVERSIZE; + } + bytes += field_bytes; + } + return bytes; +} + +/* Hash only the bounded diagnostic subset. Hashing every extracted call + * would retain unbounded side state. */ +static bool unresolved_select_diagnostics(unresolved_capture_t *capture) { + size_t selected_bytes = UNRESOLVED_JSON_ARRAY_BYTES + UNRESOLVED_TRUNCATION_RESERVE; + const CBMFileResult *result = capture->result; + for (int i = 0; i < result->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &result->resolved_calls.items[i]; + if (!unresolved_call_is_missing(rc)) { + continue; + } + const char *leaf = cbm_lsp_bare_segment(rc->callee_qn); + size_t site_bytes = unresolved_site_json_bytes(rc, rc->caller_qn, leaf, NULL); + if (capture->selected_count == UNRESOLVED_MAX_SITES || + site_bytes > UNRESOLVED_MAX_JSON_BYTES - selected_bytes) { + capture->truncated = true; + break; + } + selected_bytes += site_bytes; + capture->selected[capture->selected_count++] = rc; + char *key = unresolved_site_key("", leaf, rc->site_start_byte, rc->site_end_byte, + rc->source_origin); + if (!key) { + return false; + } + if (cbm_ht_has(capture->carriers, key)) { + cbm_free(CBM_MEM_CLASS_RESOLVE, key); + continue; + } + unresolved_carrier_t *carrier = cbm_alloc(CBM_MEM_CLASS_RESOLVE, sizeof(*carrier)); + if (!carrier) { + cbm_free(CBM_MEM_CLASS_RESOLVE, key); + return false; + } + carrier->call_index = UNRESOLVED_NO_CARRIER; + cbm_ht_set(capture->carriers, key, carrier); + if (!cbm_ht_has(capture->carriers, key)) { + cbm_free(CBM_MEM_CLASS_RESOLVE, key); + cbm_free(CBM_MEM_CLASS_RESOLVE, carrier); return false; } } return true; } +static bool unresolved_build_carriers(unresolved_capture_t *capture) { + if (!unresolved_select_diagnostics(capture)) { + return false; + } + const CBMFileResult *result = capture->result; + /* Caller names in the TS LSP walk can denote an outer factory. Join on + * span, leaf and origin; the extractor owns the enclosing function name. */ + for (int i = 0; i < result->calls.count && capture->selected_count > 0; i++) { + const CBMCall *call = &result->calls.items[i]; + if (!call->callee_name || call->site_end_byte <= call->site_start_byte) { + continue; + } + const char *leaf = cbm_lsp_bare_segment(call->callee_name); + if (unresolved_string_json_bytes(leaf) > UNRESOLVED_MAX_JSON_BYTES) { + continue; /* No bounded diagnostic key can match this leaf. */ + } + char *key = unresolved_site_key("", leaf, call->site_start_byte, call->site_end_byte, + call->source_origin); + if (!key) { + return false; + } + unresolved_carrier_t *carrier = cbm_ht_get(capture->carriers, key); + cbm_free(CBM_MEM_CLASS_RESOLVE, key); + if (carrier) { + /* An ambiguous occurrence must never erase a diagnostic. */ + carrier->call_index = + carrier->call_index == UNRESOLVED_NO_CARRIER ? i : UNRESOLVED_AMBIGUOUS_CARRIER; + } + } + return true; +} + static bool unresolved_append_site(unresolved_capture_t *capture, const CBMResolvedCall *rc, - const CBMCall *call, const char *caller, const char *leaf) { + const CBMCall *call, const CBMCallEvidence *evidence, + const char *caller, const char *leaf) { + const char *candidate = evidence ? evidence->candidate_qn : NULL; + size_t site_bytes = unresolved_site_json_bytes(rc, caller, leaf, candidate); + if (site_bytes > UNRESOLVED_MAX_JSON_BYTES - capture->json_bytes) { + capture->truncated = true; + return true; + } yyjson_mut_doc *doc = capture->doc; yyjson_mut_val *site = yyjson_mut_obj(doc); bool ok = @@ -1065,9 +1164,10 @@ static bool unresolved_append_site(unresolved_capture_t *capture, const CBMResol if (ok && call && call->start_line > 0) { ok = yyjson_mut_obj_add_int(doc, site, "line", call->start_line); } - if (ok && call && call->coverage_candidate_qn) { - ok = yyjson_mut_obj_add_strcpy(doc, site, "candidate", call->coverage_candidate_qn); + if (ok && candidate) { + ok = yyjson_mut_obj_add_strcpy(doc, site, "candidate", candidate); } + capture->json_bytes += site_bytes; return ok && yyjson_mut_arr_add_val(capture->sites, site); } @@ -1079,38 +1179,52 @@ static bool unresolved_collect_site(unresolved_capture_t *capture, const CBMReso return false; } const unresolved_carrier_t *carrier = cbm_ht_get(capture->carriers, lookup); - const CBMCall *call = carrier ? carrier->call : NULL; - cbm_free(CBM_MEM_CLASS_DUMP, lookup); - /* This exact occurrence emitted a real CALLS edge, possibly via the - * registry after LSP failure. Never suppress a different occurrence. */ - if (call && call->coverage_calls_emitted) { + int call_index = carrier ? carrier->call_index : UNRESOLVED_NO_CARRIER; + const CBMCall *call = call_index >= 0 ? &capture->result->calls.items[call_index] : NULL; + const CBMCallEvidence *evidence = + call_index >= 0 && capture->evidence ? &capture->evidence[call_index] : NULL; + cbm_free(CBM_MEM_CLASS_RESOLVE, lookup); + /* Suppress only this exact occurrence's real CALLS edge, including dedup. + * HTTP/ASYNC/CONFIGURES/SPAWNS and a candidate alone never suppress it. */ + if (evidence && evidence->calls_emitted) { return true; } const char *caller = call && call->enclosing_func_qn ? call->enclosing_func_qn : rc->caller_qn; + /* Bound both the retained dedup key and copied JSON strings. */ + size_t site_bytes = + unresolved_site_json_bytes(rc, caller, leaf, evidence ? evidence->candidate_qn : NULL); + if (site_bytes > UNRESOLVED_MAX_JSON_BYTES - capture->json_bytes) { + capture->truncated = true; + return true; + } char *key = unresolved_site_key(caller, leaf, rc->site_start_byte, rc->site_end_byte, rc->source_origin); if (!key) { return false; } if (cbm_ht_has(capture->seen, key)) { - cbm_free(CBM_MEM_CLASS_DUMP, key); + cbm_free(CBM_MEM_CLASS_RESOLVE, key); return true; } cbm_ht_set(capture->seen, key, key); if (!cbm_ht_has(capture->seen, key)) { - cbm_free(CBM_MEM_CLASS_DUMP, key); + cbm_free(CBM_MEM_CLASS_RESOLVE, key); return false; } - return unresolved_append_site(capture, rc, call, caller, leaf); + return unresolved_append_site(capture, rc, call, evidence, caller, leaf); } -static bool unresolved_collect_sites(unresolved_capture_t *capture, const CBMFileResult *result) { - for (int i = 0; i < result->resolved_calls.count; i++) { - const CBMResolvedCall *rc = &result->resolved_calls.items[i]; - if (unresolved_call_is_missing(rc) && !unresolved_collect_site(capture, rc)) { +static bool unresolved_collect_sites(unresolved_capture_t *capture) { + for (int i = 0; i < capture->selected_count; i++) { + if (!unresolved_collect_site(capture, capture->selected[i])) { return false; } } + if (capture->truncated) { + yyjson_mut_val *marker = yyjson_mut_obj(capture->doc); + return marker && yyjson_mut_obj_add_bool(capture->doc, marker, "truncated", true) && + yyjson_mut_arr_add_val(capture->sites, marker); + } return true; } @@ -1122,8 +1236,8 @@ static bool unresolved_append_row(cbm_pipeline_t *p, const char *path, const cha if (p->unresolved_count == p->unresolved_capacity) { int capacity = p->unresolved_capacity ? p->unresolved_capacity * UNRESOLVED_ROW_GROWTH : UNRESOLVED_INITIAL_ROWS; - cbm_coverage_row_t *rows = - cbm_realloc(CBM_MEM_CLASS_DUMP, p->unresolved_rows, (size_t)capacity * sizeof(*rows)); + cbm_coverage_row_t *rows = cbm_realloc(CBM_MEM_CLASS_RESOLVE, p->unresolved_rows, + (size_t)capacity * sizeof(*rows)); if (!rows) { return false; } @@ -1136,7 +1250,8 @@ static bool unresolved_append_row(cbm_pipeline_t *p, const char *path, const cha } void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path, - const CBMFileResult *result) { + const CBMFileResult *result, + const CBMCallEvidence *evidence) { if (!p || !rel_path || !result || p->unresolved_capture_failed) { return; } @@ -1147,24 +1262,32 @@ void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_pat .realloc = unresolved_json_realloc, .free = unresolved_json_free}; unresolved_capture_t capture = {.doc = yyjson_mut_doc_new(&allocator), - .carriers = cbm_ht_create(0), - .seen = cbm_ht_create(0)}; + .carriers = cbm_ht_create_in(CBM_MEM_CLASS_RESOLVE, 0), + .seen = cbm_ht_create_in(CBM_MEM_CLASS_RESOLVE, 0), + .result = result, + .evidence = evidence, + .json_bytes = UNRESOLVED_JSON_ARRAY_BYTES + + UNRESOLVED_TRUNCATION_RESERVE}; capture.sites = capture.doc ? yyjson_mut_arr(capture.doc) : NULL; bool ok = capture.doc && capture.carriers && capture.seen && capture.sites; if (capture.doc) { yyjson_mut_doc_set_root(capture.doc, capture.sites); } - ok = ok && unresolved_build_carriers(capture.carriers, result) && - unresolved_collect_sites(&capture, result); + ok = ok && unresolved_build_carriers(&capture) && unresolved_collect_sites(&capture); if (ok && yyjson_mut_arr_size(capture.sites) > 0) { char *detail = yyjson_mut_write_opts(capture.doc, 0, &allocator, NULL, NULL); - char *path = cbm_mem_strdup(CBM_MEM_CLASS_DUMP, rel_path); - cbm_mutex_lock(&p->unresolved_mutex); - ok = unresolved_append_row(p, path, detail); - cbm_mutex_unlock(&p->unresolved_mutex); + char *path = cbm_mem_strdup(CBM_MEM_CLASS_RESOLVE, rel_path); + /* The estimate reserves overhead and escaping. Enforce the serialized + * limit as well, before any detail is transferred to persistence. */ + ok = detail && strlen(detail) <= UNRESOLVED_MAX_JSON_BYTES; + if (ok) { + cbm_mutex_lock(&p->unresolved_mutex); + ok = unresolved_append_row(p, path, detail); + cbm_mutex_unlock(&p->unresolved_mutex); + } if (!ok) { - cbm_free(CBM_MEM_CLASS_DUMP, detail); - cbm_free(CBM_MEM_CLASS_DUMP, path); + cbm_free(CBM_MEM_CLASS_RESOLVE, detail); + cbm_free(CBM_MEM_CLASS_RESOLVE, path); } } if (!ok) { @@ -1232,10 +1355,10 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { p->surface_rows = NULL; p->surface_row_count = 0; for (int i = 0; i < p->unresolved_count; i++) { - cbm_free(CBM_MEM_CLASS_DUMP, (char *)p->unresolved_rows[i].rel_path); - cbm_free(CBM_MEM_CLASS_DUMP, (char *)p->unresolved_rows[i].detail); + cbm_free(CBM_MEM_CLASS_RESOLVE, (char *)p->unresolved_rows[i].rel_path); + cbm_free(CBM_MEM_CLASS_RESOLVE, (char *)p->unresolved_rows[i].detail); } - cbm_free(CBM_MEM_CLASS_DUMP, p->unresolved_rows); + cbm_free(CBM_MEM_CLASS_RESOLVE, p->unresolved_rows); cbm_mutex_destroy(&p->unresolved_mutex); cbm_git_context_free(&p->git_ctx); /* gbuf, store, registry freed during/after run */ @@ -2422,9 +2545,6 @@ static int run_sequential_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, if (seq_cache) { for (int i = 0; i < file_count; i++) { if (seq_cache[i]) { - if (rc == 0) { - cbm_pipeline_record_unresolved_calls(p, files[i].rel_path, seq_cache[i]); - } cbm_free_result(seq_cache[i]); } } @@ -3456,6 +3576,11 @@ int cbm_pipeline_finalize_staged_generation(char *path, const char *destination, return finalize_staged_impl(path, destination, cancelled, healthy, NULL); } +static bool pipeline_unresolved_capture_complete(const cbm_pipeline_t *p, + bool coverage_rows_available) { + return coverage_rows_available && !p->unresolved_capture_failed; +} + /* Dump graph to SQLite and persist file hashes for incremental indexing. */ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *baseline_manifest, int baseline_count, struct timespec *t) { @@ -3517,11 +3642,11 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas p->file_errors_count + p->excluded_count + p->ignored_count + p->unresolved_count; cbm_coverage_row_t *cov = NULL; int cov_count = 0; - bool coverage_rows_available = cov_total == 0 && !p->unresolved_capture_failed; + bool coverage_rows_available = cov_total == 0; if (cov_total > 0) { cov = malloc((size_t)cov_total * sizeof(*cov)); if (cov) { - coverage_rows_available = !p->unresolved_capture_failed; + coverage_rows_available = true; for (int i = 0; i < p->unresolved_count; i++) { cov[cov_count++] = p->unresolved_rows[i]; } @@ -3563,6 +3688,8 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas .ignored_files_total = p->ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, + .unresolved_calls_complete = + pipeline_unresolved_capture_complete(p, coverage_rows_available), }, .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 df6453c50..a6451f4b8 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1725,14 +1725,6 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed if (rc == 0) { rc = cbm_pipeline_pass_semantic(ctx, changed_files, ci); } - if (rc == 0 && cache) { - for (int i = 0; i < ci; i++) { - if (cache[i]) { - cbm_pipeline_record_unresolved_calls(ctx->pipeline, changed_files[i].rel_path, - cache[i]); - } - } - } if (rc == 0) { rc = cbm_pipeline_check_cancel(ctx); } @@ -2385,6 +2377,31 @@ done: return 1; } +static bool incremental_metadata_current(int meta_rc, const cbm_coverage_meta_t *meta, + const char *mode_name) { + return meta_rc == CBM_STORE_OK && meta->coverage_version == CBM_SEMANTIC_INDEX_VERSION && + meta->unresolved_calls_complete && meta->hash_records_complete && meta->index_mode && + strcmp(meta->index_mode, mode_name) == 0; +} + +static bool incremental_retained_capture_complete(cbm_store_t *store, const char *project) { + cbm_coverage_meta_t meta = {0}; + bool complete = cbm_store_coverage_meta_get(store, project, &meta) == CBM_STORE_OK && + meta.coverage_version >= CBM_UNRESOLVED_CALL_COVERAGE_VERSION && + meta.unresolved_calls_complete; + cbm_store_coverage_meta_clear(&meta); + return complete; +} + +static bool incremental_coverage_rows_available(int count, const cbm_coverage_row_t *rows) { + return count == 0 || rows != NULL; +} + +static bool incremental_capture_complete(bool rows_available, bool run_complete, + bool retained_complete) { + return rows_available && run_complete && retained_complete; +} + /* ── Delta-repair orchestration (closure route) ────────────────── * * The closure route's executor: clone the live generation, repair the @@ -2401,8 +2418,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char closure_plan_t *plan, cbm_file_info_t *changed_files, int ci, char **deleted, int deleted_count, cbm_file_hash_t *mode_skipped, int mode_skipped_count, cbm_coverage_row_t *old_cov, int old_cov_count, - cbm_store_t *route_store, const cbm_file_info_t *files, int file_count, - struct timespec t0) { + bool retained_unresolved_complete, cbm_store_t *route_store, + const cbm_file_info_t *files, int file_count, struct timespec t0) { struct timespec t; cbm_store_close(route_store); @@ -2705,10 +2722,10 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char &unresolved_complete); int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count + run_unresolved_count; - bool coverage_rows_available = cov_cap == 0 && unresolved_complete; + bool coverage_rows_available = cov_cap == 0; if (cov_cap > 0) { cov = (cbm_coverage_row_t *)malloc((size_t)cov_cap * sizeof(*cov)); - coverage_rows_available = cov != NULL && unresolved_complete; + coverage_rows_available = cov != NULL; } if (cov) { for (int i = 0; i < old_cov_count; i++) { @@ -2812,6 +2829,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .ignored_files_total = run_ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, + .unresolved_calls_complete = incremental_capture_complete( + coverage_rows_available, unresolved_complete, retained_unresolved_complete), }, .surface_rows = NULL, .surface_row_count = 0, @@ -2927,10 +2946,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil cbm_coverage_meta_t meta = {0}; int meta_rc = cbm_store_coverage_meta_get(store, project, &meta); const char *mode_name = incr_mode_name(cbm_pipeline_get_mode(p)); - bool metadata_current = meta_rc == CBM_STORE_OK && - meta.coverage_version == CBM_SEMANTIC_INDEX_VERSION && - meta.hash_records_complete && meta.index_mode && - strcmp(meta.index_mode, mode_name) == 0; + bool metadata_current = incremental_metadata_current(meta_rc, &meta, mode_name); bool exact = metadata_current && cbm_pipeline_semantic_manifests_equal(stored, stored_count, baseline_manifest, baseline_count); @@ -3030,6 +3046,11 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil cbm_store_free_file_hashes(stored, stored_count); + /* A subset repair cannot recover unresolved evidence omitted by an older + * attempt. Normal routing rebuilds such baselines; the legacy test route + * also preserves their incomplete signal when it retains unchanged files. */ + bool retained_unresolved_complete = incremental_retained_capture_complete(store, project); + /* Coverage rows (#963): the dump below rebuilds the DB file, wiping the * separate index_coverage table — capture the previous rows now (store * still open) so entries for files NOT re-extracted this run survive. */ @@ -3073,8 +3094,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil * live database; its every failure falls back to a full rebuild. */ return run_closure_delta(p, db_path, project, baseline_manifest, baseline_count, &closure_plan, changed_files, ci, deleted, deleted_count, - mode_skipped, mode_skipped_count, old_cov, old_cov_count, store, - files, file_count, t0); + mode_skipped, mode_skipped_count, old_cov, old_cov_count, + retained_unresolved_complete, store, files, file_count, t0); } struct timespec t; @@ -3304,7 +3325,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil if (cov_cap > 0) { cov = (cbm_coverage_row_t *)malloc((size_t)cov_cap * sizeof(*cov)); } - bool coverage_rows_available = (cov_cap == 0 || cov != NULL) && unresolved_complete; + bool coverage_rows_available = incremental_coverage_rows_available(cov_cap, cov); if (cov) { CBMHashTable *changed_set = cbm_ht_create(ci > 0 ? (size_t)ci * PAIR_LEN : CBM_SZ_64); for (int i = 0; i < ci; i++) { @@ -3392,6 +3413,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .ignored_files_total = run_ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, + .unresolved_calls_complete = incremental_capture_complete( + coverage_rows_available, unresolved_complete, retained_unresolved_complete), }; /* 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 f818bf307..95f26b687 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1196,7 +1196,7 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr /* Compatibility contract persisted in coverage metadata. Increment when a * graph/manifest semantic change makes prior exact-input indexes unsafe. */ -enum { CBM_SEMANTIC_INDEX_VERSION = CBM_UNRESOLVED_CALL_COVERAGE_VERSION }; +enum { CBM_SEMANTIC_INDEX_VERSION = 5 }; typedef struct { cbm_gbuf_t *gbuf; @@ -1281,8 +1281,15 @@ void cbm_pipeline_discard_stage(const char *stage_path); * Takes ownership; dump_and_persist_hashes writes them into the staging * store and cbm_pipeline_free releases them. Passing NULL/0 clears. */ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *rows, int count); +/* One entry per call, owned by the current file's resolve pass. Candidate + * strings are borrowed from the graph and copied by coverage capture. */ +typedef struct { + const char *candidate_qn; + bool calls_emitted; +} CBMCallEvidence; void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path, - const CBMFileResult *result); + const CBMFileResult *result, + const CBMCallEvidence *evidence); void cbm_pipeline_mark_unresolved_capture_failed(cbm_pipeline_t *p); void cbm_pipeline_get_unresolved_calls(cbm_pipeline_t *p, cbm_coverage_row_t **rows, int *count, bool *complete); diff --git a/src/store/store.c b/src/store/store.c index 996d71cc7..47ffdea85 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -347,6 +347,18 @@ static int init_schema(cbm_store_t *s) { ");" "CREATE INDEX IF NOT EXISTS idx_index_coverage_project_kind " "ON index_coverage(project, kind);" + /* Derived candidate lookup: inbound traces read only this index, not + * every file's diagnostic JSON. An empty candidate marks truncated or + * malformed evidence, whose omitted candidates cannot be ruled out. */ + "CREATE TABLE IF NOT EXISTS index_unresolved_candidates (" + " project TEXT NOT NULL," + " candidate TEXT NOT NULL," + " rel_path TEXT NOT NULL," + " kind TEXT NOT NULL DEFAULT 'unresolved_calls' CHECK(kind='unresolved_calls')," + " PRIMARY KEY(project, candidate, rel_path)," + " FOREIGN KEY(project, rel_path, kind) " + " REFERENCES index_coverage(project, rel_path, kind) ON DELETE CASCADE" + ");" /* One row per completed coverage persistence attempt. Kept separate * from projects so existing graph/artifact schema stays compatible and * a missing row unambiguously means coverage metadata is unavailable. */ @@ -359,7 +371,8 @@ static int init_schema(cbm_store_t *s) { " ignored_files_stored INTEGER NOT NULL DEFAULT 0," " ignored_files_total INTEGER NOT NULL DEFAULT 0," " coverage_version INTEGER NOT NULL DEFAULT 1," - " hash_records_complete INTEGER NOT NULL DEFAULT 0" + " hash_records_complete INTEGER NOT NULL DEFAULT 0," + " unresolved_calls_complete INTEGER NOT NULL DEFAULT 0" ");"; int rc = exec_sql(s, ddl); @@ -367,6 +380,33 @@ static int init_schema(cbm_store_t *s) { return rc; } + /* Existing writable stores retain their ordinary coverage metadata, but + * unresolved capture is untrusted until a full generation records it. */ + sqlite3_stmt *coverage_columns = NULL; + if (sqlite3_prepare_v2(s->db, "PRAGMA table_info(index_coverage_meta);", CBM_NOT_FOUND, + &coverage_columns, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "coverage meta schema prepare"); + return CBM_STORE_ERR; + } + bool have_unresolved_complete = false; + int column_rc; + while ((column_rc = sqlite3_step(coverage_columns)) == SQLITE_ROW) { + const char *name = (const char *)sqlite3_column_text(coverage_columns, SKIP_ONE); + if (name && strcmp(name, "unresolved_calls_complete") == 0) { + have_unresolved_complete = true; + } + } + sqlite3_finalize(coverage_columns); + if (column_rc != SQLITE_DONE) { + store_set_error_sqlite(s, "coverage meta schema scan"); + return CBM_STORE_ERR; + } + if (!have_unresolved_complete && + exec_sql(s, "ALTER TABLE index_coverage_meta ADD COLUMN " + "unresolved_calls_complete INTEGER NOT NULL DEFAULT 0;") != CBM_STORE_OK) { + return CBM_STORE_ERR; + } + /* Schema-compat probe (#768): DBs created before the local_name_gen * discriminator still enforce UNIQUE(source_id,target_id,type) and lack * the column — the widened upsert in cbm_store_insert_edge can neither @@ -4208,6 +4248,114 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) { 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. */ +static int coverage_index_unresolved_candidates(cbm_store_t *s, const char *project) { + static const char sql[] = + "INSERT OR IGNORE INTO index_unresolved_candidates(project, candidate, rel_path) " + "SELECT c.project, CASE WHEN j.type != 'object' OR " + "json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, '$.truncated') = 1 " + "THEN '' " + "ELSE json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, '$.candidate') " + "END, c.rel_path " + "FROM index_coverage c, json_each(CASE " + "WHEN json_valid(c.detail) THEN CASE WHEN json_type(c.detail) = 'array' " + "THEN c.detail ELSE '[{\"truncated\":true}]' END " + "ELSE '[{\"truncated\":true}]' END) j " + "WHERE c.project = ?1 AND c.kind = 'unresolved_calls' AND " + "(j.type != 'object' OR json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' " + "END, '$.truncated') = 1 OR " + "(json_type(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, '$.candidate') = " + "'text' AND " + "length(json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, " + "'$.candidate')) > 0));"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "unresolved candidate index prepare"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + store_set_error_sqlite(s, "unresolved candidate index"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +/* The caller owns the coverage transaction and rolls it back on failure. */ +static int coverage_write_metadata(cbm_store_t *s, const char *project, + const cbm_coverage_meta_t *meta) { + enum { COVERAGE_META_INITIAL_VERSION = 1 }; + if (!meta) { + sqlite3_stmt *del_meta = NULL; + if (sqlite3_prepare_v2(s->db, "DELETE FROM index_coverage_meta WHERE project = ?1;", + CBM_NOT_FOUND, &del_meta, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "coverage meta delete prepare"); + return CBM_STORE_ERR; + } + bind_text(del_meta, SKIP_ONE, project); + int meta_rc = sqlite3_step(del_meta); + sqlite3_finalize(del_meta); + if (meta_rc != SQLITE_DONE) { + store_set_error_sqlite(s, "coverage meta delete"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; + } + char recorded_at[CBM_SZ_64]; + if (meta->recorded_at && meta->recorded_at[0]) { + (void)snprintf(recorded_at, sizeof(recorded_at), "%s", meta->recorded_at); + } else { + iso_now(recorded_at, sizeof(recorded_at)); + } + const char *generation = + meta->generation && meta->generation[0] ? meta->generation : recorded_at; + const char *index_mode = meta->index_mode && meta->index_mode[0] ? meta->index_mode : "unknown"; + const char *recording_status = meta->recording_status && meta->recording_status[0] + ? meta->recording_status + : "unavailable"; + int ignored_stored = meta->ignored_files_stored > 0 ? meta->ignored_files_stored : 0; + int ignored_total = meta->ignored_files_total > 0 ? meta->ignored_files_total : 0; + int coverage_version = + meta->coverage_version > 0 ? meta->coverage_version : COVERAGE_META_INITIAL_VERSION; + + sqlite3_stmt *up_meta = NULL; + if (sqlite3_prepare_v2(s->db, + "INSERT INTO index_coverage_meta " + "(project, generation, index_mode, recorded_at, recording_status, " + " ignored_files_stored, ignored_files_total, coverage_version, " + " hash_records_complete, unresolved_calls_complete) " + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) " + "ON CONFLICT(project) DO UPDATE SET generation=?2, index_mode=?3, " + "recorded_at=?4, recording_status=?5, ignored_files_stored=?6, " + "ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9, " + "unresolved_calls_complete=?10;", + CBM_NOT_FOUND, &up_meta, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "coverage meta upsert prepare"); + return CBM_STORE_ERR; + } + bind_text(up_meta, SKIP_ONE, project); + bind_text(up_meta, ST_COL_2, generation); + bind_text(up_meta, ST_COL_3, index_mode); + bind_text(up_meta, CBM_SZ_4, recorded_at); + bind_text(up_meta, CBM_SZ_5, recording_status); + sqlite3_bind_int(up_meta, ST_COL_6, ignored_stored); + sqlite3_bind_int(up_meta, ST_COL_7, ignored_total); + sqlite3_bind_int(up_meta, ST_COL_8, coverage_version); + sqlite3_bind_int(up_meta, ST_COL_9, meta->hash_records_complete); + sqlite3_bind_int(up_meta, ST_COL_10, meta->unresolved_calls_complete); + int meta_rc = sqlite3_step(up_meta); + sqlite3_finalize(up_meta); + if (meta_rc != SQLITE_DONE) { + store_set_error_sqlite(s, "coverage meta upsert"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, const cbm_coverage_row_t *rows, int count, const cbm_coverage_meta_t *meta) { @@ -4305,72 +4453,14 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, (cov_t1.tv_sec - cov_t0.tv_sec) * 1000 + (cov_t1.tv_nsec - cov_t0.tv_nsec) / 1000000; cov_t0 = cov_t1; - if (meta) { - char recorded_at[CBM_SZ_64]; - if (meta->recorded_at && meta->recorded_at[0]) { - snprintf(recorded_at, sizeof(recorded_at), "%s", meta->recorded_at); - } else { - iso_now(recorded_at, sizeof(recorded_at)); - } - const char *generation = - meta->generation && meta->generation[0] ? meta->generation : recorded_at; - const char *index_mode = - meta->index_mode && meta->index_mode[0] ? meta->index_mode : "unknown"; - const char *recording_status = meta->recording_status && meta->recording_status[0] - ? meta->recording_status - : "unavailable"; - int ignored_stored = meta->ignored_files_stored > 0 ? meta->ignored_files_stored : 0; - int ignored_total = meta->ignored_files_total > 0 ? meta->ignored_files_total : 0; - int coverage_version = meta->coverage_version > 0 ? meta->coverage_version : 1; + if (coverage_index_unresolved_candidates(s, project) != CBM_STORE_OK) { + (void)exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; + } - sqlite3_stmt *up_meta = NULL; - if (sqlite3_prepare_v2( - s->db, - "INSERT INTO index_coverage_meta " - "(project, generation, index_mode, recorded_at, recording_status, " - " ignored_files_stored, ignored_files_total, coverage_version, " - " hash_records_complete) " - "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " - "ON CONFLICT(project) DO UPDATE SET generation=?2, index_mode=?3, " - "recorded_at=?4, recording_status=?5, ignored_files_stored=?6, " - "ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9;", - CBM_NOT_FOUND, &up_meta, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "coverage meta upsert prepare"); - (void)exec_sql(s, "ROLLBACK;"); - return CBM_STORE_ERR; - } - bind_text(up_meta, SKIP_ONE, project); - bind_text(up_meta, ST_COL_2, generation); - bind_text(up_meta, ST_COL_3, index_mode); - bind_text(up_meta, CBM_SZ_4, recorded_at); - bind_text(up_meta, CBM_SZ_5, recording_status); - sqlite3_bind_int(up_meta, 6, ignored_stored); - sqlite3_bind_int(up_meta, 7, ignored_total); - sqlite3_bind_int(up_meta, 8, coverage_version); - sqlite3_bind_int(up_meta, 9, meta->hash_records_complete ? 1 : 0); - int meta_rc = sqlite3_step(up_meta); - sqlite3_finalize(up_meta); - if (meta_rc != SQLITE_DONE) { - store_set_error_sqlite(s, "coverage meta upsert"); - (void)exec_sql(s, "ROLLBACK;"); - return CBM_STORE_ERR; - } - } else { - sqlite3_stmt *del_meta = NULL; - if (sqlite3_prepare_v2(s->db, "DELETE FROM index_coverage_meta WHERE project = ?1;", - CBM_NOT_FOUND, &del_meta, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "coverage meta delete prepare"); - (void)exec_sql(s, "ROLLBACK;"); - return CBM_STORE_ERR; - } - bind_text(del_meta, SKIP_ONE, project); - int meta_rc = sqlite3_step(del_meta); - sqlite3_finalize(del_meta); - if (meta_rc != SQLITE_DONE) { - store_set_error_sqlite(s, "coverage meta delete"); - (void)exec_sql(s, "ROLLBACK;"); - return CBM_STORE_ERR; - } + if (coverage_write_metadata(s, project, meta) != CBM_STORE_OK) { + (void)exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; } /* Rebuild the derived miss-graph view from the now-authoritative table @@ -4492,12 +4582,41 @@ int cbm_store_coverage_get_scope(cbm_store_t *s, const char *project, const char return coverage_query_rows(s, project, scope, sql, out, count); } -int cbm_store_coverage_get_unresolved_calls(cbm_store_t *s, const char *project, - cbm_coverage_row_t **out, int *count) { +int cbm_store_coverage_get_unresolved_path(cbm_store_t *s, const char *project, + const char *rel_path, cbm_coverage_row_t **out, + int *count) { static const char sql[] = "SELECT rel_path, kind, detail FROM index_coverage " - "WHERE project = ?1 AND kind = ?2 " - "ORDER BY rel_path;"; - return coverage_query_rows(s, project, "unresolved_calls", sql, out, count); + "WHERE project = ?1 AND rel_path = ?2 " + "AND kind = 'unresolved_calls';"; + return coverage_query_rows(s, project, rel_path, sql, out, count); +} + +int cbm_store_coverage_has_unresolved_candidate(cbm_store_t *s, const char *project, + const char *candidate, bool *out) { + if (!out) { + return CBM_STORE_ERR; + } + *out = false; + if (!s || !s->db || !project || !candidate) { + return CBM_STORE_ERR; + } + static const char sql[] = "SELECT 1 FROM index_unresolved_candidates " + "WHERE project = ?1 AND candidate IN ('', ?2) LIMIT 1;"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "unresolved candidate lookup prepare"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + bind_text(stmt, ST_COL_2, candidate); + int rc = sqlite3_step(stmt); + *out = rc == SQLITE_ROW; + sqlite3_finalize(stmt); + if (rc != SQLITE_ROW && rc != SQLITE_DONE) { + store_set_error_sqlite(s, "unresolved candidate lookup"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; } void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) { @@ -4524,10 +4643,20 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag if (sqlite3_prepare_v2(s->db, "SELECT project, generation, index_mode, recorded_at, recording_status, " "ignored_files_stored, ignored_files_total, coverage_version, " - "hash_records_complete FROM index_coverage_meta WHERE project = ?1;", + "hash_records_complete, unresolved_calls_complete " + "FROM index_coverage_meta WHERE project = ?1;", CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "coverage meta get prepare"); - return CBM_STORE_ERR; + /* Query opens never migrate a legacy database. Preserve its ordinary + * report while the absent new signal remains explicitly incomplete. */ + if (sqlite3_prepare_v2( + s->db, + "SELECT project, generation, index_mode, recorded_at, recording_status, " + "ignored_files_stored, ignored_files_total, coverage_version, " + "hash_records_complete, 0 FROM index_coverage_meta WHERE project = ?1;", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "coverage meta get prepare"); + return CBM_STORE_ERR; + } } bind_text(stmt, SKIP_ONE, project); int rc = sqlite3_step(stmt); @@ -4541,6 +4670,7 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag out->ignored_files_total = sqlite3_column_int(stmt, 6); out->coverage_version = sqlite3_column_int(stmt, 7); out->hash_records_complete = sqlite3_column_int(stmt, 8) != 0; + out->unresolved_calls_complete = sqlite3_column_int(stmt, ST_COL_9) != 0; sqlite3_finalize(stmt); if (!out->project || !out->generation || !out->index_mode || !out->recorded_at || !out->recording_status) { diff --git a/src/store/store.h b/src/store/store.h index de82075c7..959245c7f 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -723,6 +723,8 @@ enum { CBM_UNRESOLVED_CALL_COVERAGE_VERSION = 5 }; /* Metadata describing how completely one index run recorded the best-effort * coverage signal. `recording_status` is "complete", "truncated", or * "unavailable"; it is deliberately separate from hash_records_complete. + * unresolved_calls_complete describes only unresolved invocation capture; + * false keeps CALLS totals conservative without voiding other coverage. * Strings returned by cbm_store_coverage_meta_get are heap-owned. */ typedef struct { const char *project; @@ -734,6 +736,7 @@ typedef struct { int ignored_files_total; int coverage_version; bool hash_records_complete; + bool unresolved_calls_complete; } cbm_coverage_meta_t; /* Replace the project's coverage rows in one transaction, then prune rows for @@ -762,9 +765,14 @@ int cbm_store_coverage_get_path(cbm_store_t *s, const char *project, const char * ancestor that covers the scope. Prefix matching is segment-boundary safe. */ int cbm_store_coverage_get_scope(cbm_store_t *s, const char *project, const char *scope, cbm_coverage_row_t **out, int *count); -/* Fetch only unresolved invocation diagnostics for trace exactness checks. */ -int cbm_store_coverage_get_unresolved_calls(cbm_store_t *s, const char *project, - cbm_coverage_row_t **out, int *count); +/* Fetch bounded unresolved invocation diagnostics for one exact caller file. */ +int cbm_store_coverage_get_unresolved_path(cbm_store_t *s, const char *project, + const char *rel_path, cbm_coverage_row_t **out, + int *count); +/* Indexed candidate existence; truncated/malformed evidence also returns true + * because omitted candidates cannot prove an exact inbound total. */ +int cbm_store_coverage_has_unresolved_candidate(cbm_store_t *s, const char *project, + const char *candidate, bool *out); /* Fetch/free the metadata paired with the current coverage row set. */ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out); diff --git a/src/store/store_content_digest.c b/src/store/store_content_digest.c index c9287adee..ff15aa33e 100644 --- a/src/store/store_content_digest.c +++ b/src/store/store_content_digest.c @@ -22,6 +22,8 @@ #include #include +enum { SC_SQL_TEXT_LENGTH = -1 }; + typedef struct { const char *name; const char *sql; /* ?1 = project; every column is hashed, rows in this order */ @@ -44,11 +46,15 @@ static const sc_query_t sc_queries[] = { "main.lsp_surface WHERE project=?1 ORDER BY 1,2,3,4,5;"}, {"index_coverage", "SELECT rel_path,kind,detail FROM main.index_coverage WHERE project=?1 " "ORDER BY 1,2,3;"}, - {"index_coverage_meta", "SELECT index_mode,recording_status,ignored_files_stored," - "ignored_files_total,coverage_version,hash_records_complete FROM " - "main.index_coverage_meta WHERE project=?1;"}, + {"index_coverage_meta", + "SELECT index_mode,recording_status,ignored_files_stored," + "ignored_files_total,coverage_version,hash_records_complete,unresolved_calls_complete FROM " + "main.index_coverage_meta WHERE project=?1;"}, {"project_summaries", "SELECT summary,source_hash FROM main.project_summaries WHERE " "project=?1;"}, + {"index_unresolved_candidates", "SELECT candidate,rel_path,kind FROM " + "main.index_unresolved_candidates WHERE project=?1 " + "ORDER BY 1,2,3;"}, }; /* What test selection reads: node identity and every edge by its endpoints @@ -84,7 +90,31 @@ static void sc_frame(cbm_sha256_ctx *ctx, unsigned char tag, const void *bytes, static bool sc_table(sqlite3 *db, const sc_query_t *q, const char *project, cbm_sha256_ctx *ctx) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(db, q->sql, -1, &st, NULL) != SQLITE_OK) { - return false; + sqlite3_finalize(st); + st = NULL; + if (strcmp(q->name, "index_unresolved_candidates") == 0) { + /* Omit the optional frame only for proven legacy absence. Errors + * reading a present candidate index must invalidate the digest. */ + sqlite3_stmt *schema = NULL; + bool absent = + sqlite3_prepare_v2( + db, + "SELECT 1 FROM main.sqlite_schema WHERE name='index_unresolved_candidates' " + "COLLATE NOCASE LIMIT 1;", + SC_SQL_TEXT_LENGTH, &schema, NULL) == SQLITE_OK && + sqlite3_step(schema) == SQLITE_DONE; + return sqlite3_finalize(schema) == SQLITE_OK && absent; + } + /* Read-only legacy databases retain their existing content digest. */ + if (strcmp(q->name, "index_coverage_meta") != 0 || + sqlite3_prepare_v2(db, + "SELECT index_mode,recording_status,ignored_files_stored," + "ignored_files_total,coverage_version,hash_records_complete FROM " + "main.index_coverage_meta WHERE project=?1;", + SC_SQL_TEXT_LENGTH, &st, NULL) != SQLITE_OK) { + sqlite3_finalize(st); + return false; + } } bool ok = sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT) == SQLITE_OK; sc_frame(ctx, 'T', q->name, strlen(q->name)); diff --git a/src/store/store_graph_digest.c b/src/store/store_graph_digest.c index 9baac3c39..9cea8de80 100644 --- a/src/store/store_graph_digest.c +++ b/src/store/store_graph_digest.c @@ -16,9 +16,14 @@ enum { GD_ALLOW_INTEGER = 1 << GD_INTEGER, GD_ALLOW_TEXT = 1 << GD_TEXT, GD_ALLOW_BLOB = 1 << GD_BLOB, - GD_TABLE_COUNT = 9, - GD_MAX_COLUMNS = 9, + GD_TABLE_COUNT = 10, + GD_MAX_COLUMNS = 10, + GD_COVERAGE_META = 7, GD_STORE_META = 8, + GD_UNRESOLVED_CANDIDATES = 9, + GD_SCHEMA_DECLARED_TYPE = 2, + GD_SCHEMA_PRIMARY_KEY = 5, + GD_SCHEMA_HIDDEN = 6, GD_CHUNK_BYTES = 65536 }; @@ -87,9 +92,15 @@ static const gd_column_t gd_index_coverage_meta[] = {{"project", GD_T, 0, 1}, {"ignored_files_stored", GD_I, 0, 0}, {"ignored_files_total", GD_I, 0, 0}, {"coverage_version", GD_I, 0, 0}, - {"hash_records_complete", GD_I, 0, 0}}; + {"hash_records_complete", GD_I, 0, 0}, + {"unresolved_calls_complete", GD_I, 0, 0}}; static const gd_column_t gd_store_meta[] = {{"k", GD_T, 0, 1}, {"v", GD_T, 0, 0}}; +static const gd_column_t gd_unresolved_candidates[] = {{"project", GD_T, 0, 1}, + {"candidate", GD_T, 0, 2}, + {"rel_path", GD_T, 0, 3}, + {"kind", GD_T, 0, 0}}; + /* These descriptors and SQL strings are immutable; all other state is local. */ static const gd_table_t gd_tables[GD_TABLE_COUNT] = { {"projects", gd_projects, GD_COLUMNS(gd_projects), "PRAGMA main.table_xinfo('projects');", @@ -116,14 +127,28 @@ static const gd_table_t gd_tables[GD_TABLE_COUNT] = { "PRAGMA main.table_xinfo('index_coverage');", "SELECT project,rel_path,kind,detail FROM main.index_coverage " "ORDER BY CAST(project AS BLOB) ASC,CAST(rel_path AS BLOB) ASC,CAST(kind AS BLOB) ASC;"}, - {"index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta), + {"index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta) - 1, "PRAGMA main.table_xinfo('index_coverage_meta');", "SELECT project,generation,index_mode,recorded_at,recording_status,ignored_files_stored," "ignored_files_total,coverage_version,hash_records_complete FROM main.index_coverage_meta " "ORDER BY CAST(project AS BLOB) ASC;"}, {"store_meta", gd_store_meta, GD_COLUMNS(gd_store_meta), "PRAGMA main.table_xinfo('store_meta');", - "SELECT k,v FROM main.store_meta ORDER BY CAST(k AS BLOB) ASC;"}}; + "SELECT k,v FROM main.store_meta ORDER BY CAST(k AS BLOB) ASC;"}, + {"index_unresolved_candidates", gd_unresolved_candidates, GD_COLUMNS(gd_unresolved_candidates), + "PRAGMA main.table_xinfo('index_unresolved_candidates');", + "SELECT project,candidate,rel_path,kind FROM main.index_unresolved_candidates " + "ORDER BY CAST(project AS BLOB) ASC,CAST(candidate AS BLOB) ASC," + "CAST(rel_path AS BLOB) ASC,CAST(kind AS BLOB) ASC;"}}; + +/* Preserve the legacy canonical stream; newer metadata binds the independent + * capture completeness column as well. Both schemas are checked exactly. */ +static const gd_table_t gd_coverage_meta_current = { + "index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta), + "PRAGMA main.table_xinfo('index_coverage_meta');", + "SELECT project,generation,index_mode,recorded_at,recording_status,ignored_files_stored," + "ignored_files_total,coverage_version,hash_records_complete,unresolved_calls_complete " + "FROM main.index_coverage_meta ORDER BY CAST(project AS BLOB) ASC;"}; #undef GD_B #undef GD_N @@ -142,8 +167,14 @@ typedef struct { uint64_t bytes; bool have_uid; bool have_counter; + bool have_unresolved_completeness; } gd_context_t; +static const gd_table_t *gd_table(const gd_context_t *g, int index) { + return index == GD_COVERAGE_META && g->have_unresolved_completeness ? &gd_coverage_meta_current + : &gd_tables[index]; +} + typedef struct { const unsigned char *data; size_t length; @@ -408,8 +439,30 @@ static bool gd_table_kinds(gd_context_t *g, bool present[GD_TABLE_COUNT]) { return rc == SQLITE_DONE && gd_finalize(g); } +static bool gd_column_schema(gd_context_t *g, const gd_column_t *expected) { + if (sqlite3_column_type(g->stmt, GD_SCHEMA_PRIMARY_KEY) != SQLITE_INTEGER || + sqlite3_column_type(g->stmt, GD_SCHEMA_HIDDEN) != SQLITE_INTEGER || + sqlite3_column_int64(g->stmt, GD_SCHEMA_PRIMARY_KEY) != expected->primary_key || + sqlite3_column_int64(g->stmt, GD_SCHEMA_HIDDEN) != expected->kind) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + if (expected->primary_key && expected->types == GD_ALLOW_INTEGER) { + gd_text_t declared_type; + if (!gd_read_text(g, GD_SCHEMA_DECLARED_TYPE, &declared_type)) { + return false; + } + if (!gd_text_ascii_eq(declared_type, "INTEGER")) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + } + return true; +} + static bool gd_columns(gd_context_t *g, const gd_table_t *table) { bool seen[GD_MAX_COLUMNS] = {false}; + bool coverage_meta = table == &gd_tables[GD_COVERAGE_META]; + int allowed_columns = + coverage_meta ? gd_coverage_meta_current.column_count : table->column_count; if (!gd_prepare(g, table->column_query)) { return false; } @@ -424,37 +477,30 @@ static bool gd_columns(gd_context_t *g, const gd_table_t *table) { return false; } int column = -1; - for (int i = 0; i < table->column_count; i++) { + for (int i = 0; i < allowed_columns; i++) { if (gd_text_eq(name, table->columns[i].name)) { column = i; break; } } - if (column < 0 || seen[column] || count == table->column_count || - sqlite3_column_type(g->stmt, 5) != SQLITE_INTEGER || - sqlite3_column_type(g->stmt, 6) != SQLITE_INTEGER) { + if (column < 0 || seen[column] || count == allowed_columns || + !gd_column_schema(g, &table->columns[column])) { return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); } - const gd_column_t *expected = &table->columns[column]; - if (sqlite3_column_int64(g->stmt, 5) != expected->primary_key || - sqlite3_column_int64(g->stmt, 6) != expected->kind) { - return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); - } - if (expected->primary_key && expected->types == GD_ALLOW_INTEGER) { - gd_text_t declared_type; - if (!gd_read_text(g, 2, &declared_type)) { - return false; - } - if (!gd_text_ascii_eq(declared_type, "INTEGER")) { - return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); - } - } seen[column] = true; count++; } - if (rc != SQLITE_DONE || count != table->column_count) { + if (rc != SQLITE_DONE || count < table->column_count) { return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); } + for (int i = 0; i < table->column_count; i++) { + if (!seen[i]) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + } + if (coverage_meta) { + g->have_unresolved_completeness = seen[table->column_count]; + } return gd_finalize(g); } @@ -481,8 +527,21 @@ static bool gd_project(gd_context_t *g, const unsigned char *project, size_t len return rc == SQLITE_DONE && gd_finalize(g); } +static bool gd_validate_schema(gd_context_t *g, bool present[GD_TABLE_COUNT], + const unsigned char *project, size_t length) { + if (!gd_encoding(g) || !gd_table_kinds(g, present)) { + return false; + } + for (int i = 0; i < GD_TABLE_COUNT; i++) { + if (present[i] && !gd_columns(g, &gd_tables[i])) { + return false; + } + } + return gd_project(g, project, length); +} + static bool gd_table_header(gd_context_t *g, int index, bool present) { - const gd_table_t *table = &gd_tables[index]; + const gd_table_t *table = gd_table(g, index); if (!gd_u8(g, 0x20) || !gd_u32(g, (uint32_t)index) || !gd_literal(g, table->name) || !gd_u32(g, (uint32_t)table->column_count)) { return false; @@ -605,7 +664,7 @@ static bool gd_column_value(gd_context_t *g, int index, const gd_column_t *colum static bool gd_table_rows(gd_context_t *g, int index, bool present) { uint64_t rows = 0; - const gd_table_t *table = &gd_tables[index]; + const gd_table_t *table = gd_table(g, index); if (present) { if (!gd_prepare(g, table->row_query)) { return false; @@ -645,6 +704,19 @@ static bool gd_table_rows(gd_context_t *g, int index, bool present) { return gd_u8(g, 0x22) && gd_u64(g, rows); } +static bool gd_stream_tables(gd_context_t *g, const bool present[GD_TABLE_COUNT]) { + for (int i = 0; i < GD_TABLE_COUNT; i++) { + /* Legacy stores retain their byte-for-byte canonical stream. */ + if (i == GD_UNRESOLVED_CANDIDATES && !present[i]) { + continue; + } + if (!gd_table_header(g, i, present[i]) || !gd_table_rows(g, i, present[i])) { + return false; + } + } + return true; +} + static bool gd_project_argument(gd_context_t *g, const unsigned char *project, size_t length) { while (length) { size_t chunk = length > GD_CHUNK_BYTES ? GD_CHUNK_BYTES : length; @@ -694,15 +766,7 @@ cbm_store_graph_digest_status_t cbm_store_graph_digest( return g.status; } bool present[GD_TABLE_COUNT] = {false}; - if (!gd_encoding(&g) || !gd_table_kinds(&g, present)) { - goto done; - } - for (int i = 0; i < GD_TABLE_COUNT; i++) { - if (present[i] && !gd_columns(&g, &gd_tables[i])) { - goto done; - } - } - if (!gd_project(&g, project, project_len)) { + if (!gd_validate_schema(&g, present, project, project_len)) { goto done; } cbm_sha256_init(&g.sha); @@ -711,10 +775,8 @@ cbm_store_graph_digest_status_t cbm_store_graph_digest( !gd_u8(&g, 0x10) || !gd_value(&g, GD_TEXT, project, project_len)) { goto done; } - for (int i = 0; i < GD_TABLE_COUNT; i++) { - if (!gd_table_header(&g, i, present[i]) || !gd_table_rows(&g, i, present[i])) { - goto done; - } + if (!gd_stream_tables(&g, present)) { + goto done; } if (!gd_u8(&g, 0x7f) || !gd_u64(&g, g.rows) || !gd_check(&g)) { goto done; diff --git a/src/store/store_graph_digest.h b/src/store/store_graph_digest.h index 004ed7840..d7276fd33 100644 --- a/src/store/store_graph_digest.h +++ b/src/store/store_graph_digest.h @@ -33,7 +33,8 @@ typedef struct { } cbm_store_graph_digest_t; /* Borrow a live, exclusive D5 scope. Hash the version-1 canonical stream of - * all nine known tables, including their exact CREATE SQL and explicit project + * the nine legacy tables and the unresolved candidate index when present, + * including their exact CREATE SQL and explicit project * identity. The database must use UTF-8. No new handler or transaction is used. * Independent scopes/connections may run concurrently; no mutable state is * shared. The callback/connection restrictions of D5 continue to apply. diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 0f7a900ba..ab3e412fe 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -6937,6 +6937,7 @@ TEST(tool_trace_budget_never_slices_identifiers) { .recorded_at = "2026-09-24T00:00:00Z", .recording_status = "complete", .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .unresolved_calls_complete = true, .hash_records_complete = true}; ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, NULL, 0, &coverage_meta), CBM_STORE_OK); @@ -7289,6 +7290,7 @@ TEST(tool_trace_reports_engine_saturation_as_lower_bound) { .recorded_at = "2026-09-24T00:00:00Z", .recording_status = "complete", .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .unresolved_calls_complete = true, .hash_records_complete = true}; ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, NULL, 0, &coverage_meta), CBM_STORE_OK); cbm_node_t hub = {.project = project, @@ -7781,6 +7783,99 @@ TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped) { PASS(); } +/* Unrelated file evidence must not poison a clean caller's exact outbound + * count. Inbound checks use their materialized candidate index, so corrupting + * an unrelated JSON payload after publication must not trigger a full scan. */ +TEST(tool_trace_path_reads_only_relevant_unresolved_evidence) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + const char *project = "bounded-trace"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/bounded-trace"), CBM_STORE_OK); + cbm_node_t root = {.project = project, + .label = "Function", + .name = "run", + .qualified_name = "bounded-trace.run", + .file_path = "clean.js", + .start_line = 1, + .end_line = 4}; + ASSERT_GT(cbm_store_upsert_node(store, &root), 0); + ASSERT_EQ(cbm_store_upsert_file_hash(store, project, "unrelated.js", "fixture", 0, 0), + CBM_STORE_OK); + cbm_coverage_row_t row = {.rel_path = "unrelated.js", + .kind = "unresolved_calls", + .detail = "[{\"caller\":\"bounded-trace.other\",\"leaf\":\"other\"," + "\"candidate\":\"bounded-trace.other\"}]"}; + cbm_coverage_meta_t meta = {.generation = "fixture", + .index_mode = "full", + .recording_status = "complete", + .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .unresolved_calls_complete = true, + .hash_records_complete = true}; + ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, &row, 1, &meta), CBM_STORE_OK); + ASSERT_EQ(cbm_store_exec(store, "UPDATE index_coverage SET detail='broken json' " + "WHERE project='bounded-trace';"), + CBM_STORE_OK); + const char *directions[] = {"outbound", "inbound"}; + const char *fields[] = {"callees_total_relation", "callers_total_relation"}; + for (int i = 0; i < 2; i++) { + char args[256], expected[80]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"run\"," + "\"direction\":\"%s\",\"format\":\"json\"}", + project, directions[i]); + char *out = cbm_mcp_handle_tool(srv, "trace_path", args); + ASSERT_NOT_NULL(out); + snprintf(expected, sizeof(expected), "\"%s\":\"eq\"", fields[i]); + ASSERT(response_contains_json_fragment(out, expected)); + free(out); + } + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_trace_path_truncated_evidence_is_conservative) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + const char *project = "truncated-trace"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/truncated-trace"), CBM_STORE_OK); + cbm_node_t root = {.project = project, .label = "Function", .name = "run", + .qualified_name = "truncated-trace.run", .file_path = "clean.js", + .start_line = 1, .end_line = 4}; + ASSERT_GT(cbm_store_upsert_node(store, &root), 0); + ASSERT_EQ(cbm_store_upsert_file_hash(store, project, "unrelated.js", "fixture", 0, 0), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(store, project, "clean.js", "fixture", 0, 0), CBM_STORE_OK); + cbm_coverage_meta_t meta = {.generation = "fixture", .index_mode = "full", + .recording_status = "complete", .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .unresolved_calls_complete = true, .hash_records_complete = true}; + const char *details[] = {"[{\"truncated\":true}]", "[null]", "broken json"}; + const char *directions[] = {"outbound", "inbound"}; + const char *fields[] = {"callees_total_relation", "callers_total_relation"}; + for (size_t detail = 0; detail < sizeof(details) / sizeof(details[0]); detail++) { + for (int relevant = 0; relevant < 2; relevant++) { + cbm_coverage_row_t row = {.rel_path = relevant ? "clean.js" : "unrelated.js", + .kind = "unresolved_calls", .detail = details[detail]}; + ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, &row, 1, &meta), CBM_STORE_OK); + for (int direction = 0; direction < 2; direction++) { + char args[256], expected[80]; + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"function_name\":\"run\"," + "\"direction\":\"%s\",\"format\":\"json\"}", project, directions[direction]); + char *out = cbm_mcp_handle_tool(srv, "trace_path", args); + ASSERT_NOT_NULL(out); + snprintf(expected, sizeof(expected), "\"%s\":\"%s\"", fields[direction], + relevant || direction == 1 ? "unknown" : "eq"); + ASSERT(response_contains_json_fragment(out, expected)); + free(out); + } + } + } + cbm_mcp_server_free(srv); + PASS(); +} + TEST(tool_trace_path_marks_unresolved_call_totals_unknown) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); cbm_store_t *st = cbm_mcp_server_store(srv); @@ -7825,6 +7920,7 @@ TEST(tool_trace_path_marks_unresolved_call_totals_unknown) { .recorded_at = "2026-09-24T00:00:00Z", .recording_status = "complete", .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .unresolved_calls_complete = true, .hash_records_complete = true}; ASSERT_EQ(cbm_store_coverage_replace_ex(st, proj, &row, 1, &meta), CBM_STORE_OK); @@ -7931,6 +8027,7 @@ TEST(tool_trace_path_outbound_requires_project_symbol) { .recorded_at = "2026-09-29T00:00:00Z", .recording_status = "complete", .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .unresolved_calls_complete = true, .hash_records_complete = true}; const struct { const char *leaf; @@ -23282,7 +23379,44 @@ TEST(search_code_rejects_quote_in_file_pattern) { PASS(); } +TEST(tool_check_index_coverage_parse_gap_precedes_unresolved_calls) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + const char *project = "coverage-priority"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/coverage-priority"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(store, project, "mixed.js", "fixture", 0, 0), + CBM_STORE_OK); + const char *kinds[] = {"parse_partial", "parse_unusable", "oversized", "not_indexed_file"}; + const char *statuses[] = {"partial", "unusable", "skipped", "excluded"}; + for (size_t i = 0; i < sizeof(kinds) / sizeof(kinds[0]); i++) { + cbm_coverage_row_t rows[] = { + {.rel_path = "mixed.js", .kind = "unresolved_calls", .detail = "[]"}, + {.rel_path = "mixed.js", .kind = kinds[i], .detail = "1-2"}, + }; + ASSERT_EQ(cbm_store_coverage_replace(store, project, rows, 2), CBM_STORE_OK); + char *response = cbm_mcp_handle_tool( + srv, "check_index_coverage", + "{\"project\":\"coverage-priority\",\"paths\":[\"mixed.js\"],\"format\":\"json\"}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *path = yyjson_arr_get(yyjson_obj_get(yyjson_doc_get_root(doc), "paths"), 0); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(path, "status")), statuses[i]); + ASSERT_EQ(yyjson_arr_size(yyjson_obj_get(path, "coverage")), 2); + yyjson_doc_free(doc); + free(inner); + free(response); + } + cbm_mcp_server_free(srv); + PASS(); +} + SUITE(mcp) { + RUN_TEST(tool_check_index_coverage_parse_gap_precedes_unresolved_calls); RUN_TEST(index_repository_async_and_status_refused_without_daemon_issue2144); RUN_TEST(index_repository_schema_documents_async_polling_issue2144); RUN_TEST(tool_result_add_notice_keeps_payload_shape_issue2144); @@ -23474,6 +23608,8 @@ SUITE(mcp) { RUN_TEST(tool_trace_call_path_qn_fallback_frees_name_miss); RUN_TEST(trace_evidence_strategy_class_vocabulary_is_closed); RUN_TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped); + RUN_TEST(tool_trace_path_reads_only_relevant_unresolved_evidence); + RUN_TEST(tool_trace_path_truncated_evidence_is_conservative); RUN_TEST(tool_trace_path_marks_unresolved_call_totals_unknown); RUN_TEST(tool_trace_path_outbound_requires_project_symbol); RUN_TEST(tool_index_unresolved_nested_functions_are_indexed); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 859f295f0..6d16917ed 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -857,6 +857,65 @@ static int pipeline_unresolved_review_case(int padding, bool spill) { PASS(); } +/* A noisy semantic resolver must retain an explicit bounded-coverage marker, + * including when a single reason would exceed the entire detail budget. */ +TEST(pipeline_unresolved_capture_bounds_count_and_json_bytes) { + char tmp[256] = "/tmp/cbm_unresolved_cap_XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char db[512]; + snprintf(db, sizeof(db), "%s/index.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + CBMResolvedCall *sites = calloc(1001, sizeof(*sites)); + ASSERT_NOT_NULL(sites); + for (int i = 0; i < 1001; i++) { + sites[i] = (CBMResolvedCall){.kind = CBM_RESOLVED_INVOCATION, + .caller_qn = "cap.run", + .callee_qn = "missing", + .strategy = "lsp_unresolved", + .site_start_byte = (uint32_t)i * 2, + .site_end_byte = (uint32_t)i * 2 + 1}; + } + CBMFileResult result = {.resolved_calls = {.items = sites, .count = 1001}}; + cbm_pipeline_record_unresolved_calls(p, "many.js", &result, NULL); + cbm_coverage_row_t *rows = NULL; + int count = 0; + bool complete = false; + cbm_pipeline_get_unresolved_calls(p, &rows, &count, &complete); + ASSERT_TRUE(complete); + ASSERT_EQ(count, 1); + ASSERT_TRUE(strlen(rows[0].detail) <= 128 * 1024); + yyjson_doc *doc = yyjson_read(rows[0].detail, strlen(rows[0].detail), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *array = yyjson_doc_get_root(doc); + ASSERT_TRUE(yyjson_arr_size(array) <= 1001); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(yyjson_arr_get_last(array), "truncated"))); + yyjson_doc_free(doc); + + char *reason = malloc(200000); + ASSERT_NOT_NULL(reason); + memset(reason, '"', 199999); + reason[199999] = '\0'; + sites[0].reason = reason; + result.resolved_calls.count = 1; + cbm_pipeline_record_unresolved_calls(p, "huge.js", &result, NULL); + cbm_pipeline_get_unresolved_calls(p, &rows, &count, &complete); + ASSERT_TRUE(complete); + ASSERT_EQ(count, 2); + ASSERT_TRUE(strlen(rows[1].detail) <= 128 * 1024); + doc = yyjson_read(rows[1].detail, strlen(rows[1].detail), 0); + ASSERT_NOT_NULL(doc); + array = yyjson_doc_get_root(doc); + ASSERT_EQ(yyjson_arr_size(array), 1); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(yyjson_arr_get_last(array), "truncated"))); + yyjson_doc_free(doc); + free(reason); + free(sites); + cbm_pipeline_free(p); + rm_rf(tmp); + PASS(); +} + TEST(pipeline_unresolved_review_sequential) { return pipeline_unresolved_review_case(0, false); } @@ -5251,6 +5310,81 @@ TEST(pipeline_tsconfig_mutation_before_publication_preserves_previous_generation PASS(); } +static void fail_unresolved_capture_before_publication(void *userdata) { + cbm_pipeline_mark_unresolved_capture_failed(userdata); + cbm_pipeline_add_file_error(userdata, "generation.py", "1-1", "parse_partial"); +} + +TEST(pipeline_capture_failure_preserves_general_coverage_and_rebuilds) { + char tmp[256] = "/tmp/cbm_capture_metadata_XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "generation.py", "def GeneralCoverage():\n return 1\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_t *first = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST); + ASSERT_NOT_NULL(first); + cbm_pipeline_incremental_test_before_final_manifest_once( + fail_unresolved_capture_before_publication, first); + ASSERT_EQ(cbm_pipeline_run(first), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(first)); + cbm_pipeline_free(first); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + cbm_coverage_meta_t meta = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(store, project, &meta), CBM_STORE_OK); + ASSERT_STR_EQ(meta.recording_status, "complete"); + ASSERT_FALSE(meta.unresolved_calls_complete); + cbm_coverage_row_t *rows = NULL; + int row_count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(store, project, "generation.py", &rows, &row_count), + CBM_STORE_OK); + ASSERT_EQ(row_count, 1); + ASSERT_STR_EQ(rows[0].kind, "parse_partial"); + cbm_store_free_coverage(rows, row_count); + cbm_store_coverage_meta_clear(&meta); + cbm_store_close(store); + /* A partial repair cannot fill an omitted signal for unchanged files. */ + write_temp_file(tmp, "changed.py", "def NewCoverage():\n return 2\n"); + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_force_legacy_partial_once(); + cbm_pipeline_t *partial = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST); + ASSERT_NOT_NULL(partial); + ASSERT_EQ(cbm_pipeline_run(partial), 0); + ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL); + cbm_pipeline_free(partial); + store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_coverage_meta_get(store, project, &meta), CBM_STORE_OK); + ASSERT_STR_EQ(meta.recording_status, "complete"); + ASSERT_FALSE(meta.unresolved_calls_complete); + cbm_store_coverage_meta_clear(&meta); + rows = NULL; + row_count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(store, project, "generation.py", &rows, &row_count), + CBM_STORE_OK); + ASSERT_EQ(row_count, 1); + ASSERT_STR_EQ(rows[0].kind, "parse_partial"); + cbm_store_free_coverage(rows, row_count); + cbm_store_close(store); + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_t *repair = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST); + ASSERT_NOT_NULL(repair); + ASSERT_EQ(cbm_pipeline_run(repair), 0); + ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_FORCED_FULL); + cbm_pipeline_free(repair); + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_t *unchanged = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST); + ASSERT_NOT_NULL(unchanged); + ASSERT_EQ(cbm_pipeline_run(unchanged), 0); + ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_NOOP); + cbm_pipeline_free(unchanged); + cbm_pipeline_incremental_test_reset_faults(); + th_rmtree(tmp); + PASS(); +} + /* Metadata participates in exact-input compatibility. Old coverage schema or * an upgrade to a more comprehensive discovery/index mode must force a * complete replacement even when every semantic-input byte is unchanged; the @@ -18603,6 +18737,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_export_error_snapshot_on_artifact_failure); RUN_TEST(pipeline_records_unresolved_injected_call_sites); RUN_TEST(pipeline_unresolved_review_sequential); + RUN_TEST(pipeline_unresolved_capture_bounds_count_and_json_bytes); RUN_TEST(pipeline_unresolved_review_parallel); RUN_TEST(pipeline_unresolved_review_spill); RUN_TEST(pipeline_structure_edges); @@ -18990,6 +19125,7 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_source_addition_before_publication_preserves_previous_generation); RUN_TEST(pipeline_tsconfig_mutation_before_publication_preserves_previous_generation); RUN_TEST(pipeline_exact_inputs_migrate_coverage_metadata_and_index_mode); + RUN_TEST(pipeline_capture_failure_preserves_general_coverage_and_rebuilds); RUN_TEST(pipeline_existing_artifact_refreshes_after_default_forced_full_reindex); RUN_TEST(pipeline_full_cancel_after_predump_preserves_previous_generation); RUN_TEST(pipeline_full_cancel_after_destination_prepare_preserves_previous_generation); diff --git a/tests/test_store_bulk.c b/tests/test_store_bulk.c index 826cef87e..e7991b7ac 100644 --- a/tests/test_store_bulk.c +++ b/tests/test_store_bulk.c @@ -167,9 +167,265 @@ TEST(bulk_crash_recovery) { } #endif /* _WIN32 */ +TEST(coverage_meta_has_independent_unresolved_completeness) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + sqlite3_stmt *stmt = NULL; + ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(s), + "SELECT unresolved_calls_complete FROM index_coverage_meta;", -1, + &stmt, NULL), + SQLITE_OK); + sqlite3_finalize(stmt); + cbm_store_close(s); + PASS(); +} + +TEST(coverage_meta_legacy_reader_preserves_general_metadata) { + char path[256]; + make_temp_path(path, sizeof(path)); + cleanup_db(path); + cbm_store_t *writer = cbm_store_open_path(path); + ASSERT_NOT_NULL(writer); + ASSERT_EQ(cbm_store_upsert_project(writer, "legacy-coverage", "/tmp/legacy-coverage"), + CBM_STORE_OK); + ASSERT_EQ( + sqlite3_exec(cbm_store_get_db(writer), + "DROP TABLE index_coverage_meta;" + "CREATE TABLE index_coverage_meta(project TEXT PRIMARY KEY,generation TEXT," + "index_mode TEXT,recorded_at TEXT,recording_status TEXT," + "ignored_files_stored INTEGER,ignored_files_total INTEGER," + "coverage_version INTEGER,hash_records_complete INTEGER);" + "INSERT INTO index_coverage_meta VALUES('legacy-coverage','generation','full'," + "'recorded','truncated',1,2,5,1);", + NULL, NULL, NULL), + SQLITE_OK); + cbm_store_close(writer); + cbm_store_t *reader = cbm_store_open_path_query(path); + ASSERT_NOT_NULL(reader); + cbm_coverage_meta_t meta = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(reader, "legacy-coverage", &meta), CBM_STORE_OK); + ASSERT_STR_EQ(meta.recording_status, "truncated"); + ASSERT_EQ(meta.ignored_files_stored, 1); + ASSERT_EQ(meta.ignored_files_total, 2); + ASSERT_EQ(meta.coverage_version, 5); + ASSERT_TRUE(meta.hash_records_complete); + ASSERT_FALSE(meta.unresolved_calls_complete); + cbm_store_coverage_meta_clear(&meta); + cbm_store_close(reader); + writer = cbm_store_open_path(path); + ASSERT_NOT_NULL(writer); + sqlite3_stmt *stmt = NULL; + ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(writer), + "SELECT unresolved_calls_complete FROM index_coverage_meta;", -1, + &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_EQ(sqlite3_column_int(stmt, 0), 0); + sqlite3_finalize(stmt); + ASSERT_EQ(cbm_store_coverage_meta_get(writer, "legacy-coverage", &meta), CBM_STORE_OK); + ASSERT_STR_EQ(meta.recording_status, "truncated"); + ASSERT_FALSE(meta.unresolved_calls_complete); + cbm_store_coverage_meta_clear(&meta); + cbm_store_close(writer); + cleanup_db(path); + PASS(); +} + +TEST(coverage_meta_completeness_round_trip_and_failed_replace) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "capture-meta"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/capture-meta"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "gap.js", "fixture", 0, 0), CBM_STORE_OK); + cbm_coverage_row_t row = {.rel_path = "gap.js", .kind = "parse_partial", .detail = "2-3"}; + cbm_coverage_meta_t meta = {.generation = "baseline", + .index_mode = "full", + .recording_status = "complete", + .coverage_version = 5, + .hash_records_complete = true, + .unresolved_calls_complete = true}; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, &meta), CBM_STORE_OK); + cbm_coverage_meta_t fetched = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK); + ASSERT_TRUE(fetched.unresolved_calls_complete); + cbm_store_coverage_meta_clear(&fetched); + ASSERT_EQ( + sqlite3_exec(cbm_store_get_db(s), + "CREATE TRIGGER reject_capture_meta BEFORE UPDATE ON index_coverage_meta " + "BEGIN SELECT RAISE(ABORT, 'metadata fault'); END;", + NULL, NULL, NULL), + SQLITE_OK); + meta.generation = "failed"; + meta.unresolved_calls_complete = false; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, NULL, 0, &meta), CBM_STORE_ERR); + ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK); + ASSERT_STR_EQ(fetched.generation, "baseline"); + ASSERT_TRUE(fetched.unresolved_calls_complete); + cbm_store_coverage_meta_clear(&fetched); + cbm_coverage_row_t *rows = NULL; + int count = 0; + ASSERT_EQ(cbm_store_coverage_get(s, project, &rows, &count), CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(rows[0].kind, "parse_partial"); + cbm_store_free_coverage(rows, count); + ASSERT_EQ( + sqlite3_exec(cbm_store_get_db(s), "DROP TRIGGER reject_capture_meta;", NULL, NULL, NULL), + SQLITE_OK); + ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, &meta), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK); + ASSERT_FALSE(fetched.unresolved_calls_complete); + ASSERT_STR_EQ(fetched.recording_status, "complete"); + cbm_store_coverage_meta_clear(&fetched); + ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, NULL), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_NOT_FOUND); + cbm_store_close(s); + PASS(); +} + +typedef struct { + int statements; + int fullscan_steps; + int vm_steps; +} unresolved_query_cost_t; + +static int record_unresolved_query_cost(unsigned type, void *userdata, void *statement, + void *elapsed) { + (void)elapsed; + unresolved_query_cost_t *cost = userdata; + sqlite3_stmt *stmt = statement; + const char *sql = sqlite3_sql(stmt); + if (type == SQLITE_TRACE_PROFILE && sql && + (strstr(sql, "FROM index_unresolved_candidates") || + strstr(sql, "AND kind = 'unresolved_calls'"))) { + cost->statements++; + cost->fullscan_steps += sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_FULLSCAN_STEP, 0); + cost->vm_steps += sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_VM_STEP, 0); + } + return 0; +} + +TEST(unresolved_coverage_queries_use_file_and_candidate_indexes) { + enum { UNRELATED_FILES = 2000 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "bounded-lookups"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/bounded-lookups"), CBM_STORE_OK); + sqlite3 *db = cbm_store_get_db(s); + ASSERT_EQ(sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL), SQLITE_OK); + sqlite3_stmt *coverage = NULL; + sqlite3_stmt *candidate = NULL; + ASSERT_EQ(sqlite3_prepare_v2(db, + "INSERT INTO index_coverage(project,rel_path,kind,detail) " + "VALUES(?1,?2,'unresolved_calls',?3);", + -1, &coverage, NULL), + SQLITE_OK); + ASSERT_EQ( + sqlite3_prepare_v2(db, + "INSERT INTO index_unresolved_candidates(project,candidate,rel_path) " + "VALUES(?1,?2,?3);", + -1, &candidate, NULL), + SQLITE_OK); + for (int i = 0; i <= UNRELATED_FILES; i++) { + char path[64]; + snprintf(path, sizeof(path), "file-%04d.js", i); + ASSERT_EQ(sqlite3_bind_text(coverage, 1, project, -1, SQLITE_STATIC), SQLITE_OK); + ASSERT_EQ(sqlite3_bind_text(coverage, 2, path, -1, SQLITE_TRANSIENT), SQLITE_OK); + ASSERT_EQ(sqlite3_bind_text(coverage, 3, + i == 0 ? "[{\"candidate\":\"target.fn\"}]" + : "[{\"candidate\":\"other.fn\"}]", + -1, SQLITE_STATIC), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(coverage), SQLITE_DONE); + ASSERT_EQ(sqlite3_reset(coverage), SQLITE_OK); + ASSERT_EQ(sqlite3_bind_text(candidate, 1, project, -1, SQLITE_STATIC), SQLITE_OK); + ASSERT_EQ( + sqlite3_bind_text(candidate, 2, i == 0 ? "target.fn" : "other.fn", -1, SQLITE_STATIC), + SQLITE_OK); + ASSERT_EQ(sqlite3_bind_text(candidate, 3, path, -1, SQLITE_TRANSIENT), SQLITE_OK); + ASSERT_EQ(sqlite3_step(candidate), SQLITE_DONE); + ASSERT_EQ(sqlite3_reset(candidate), SQLITE_OK); + } + sqlite3_finalize(coverage); + sqlite3_finalize(candidate); + ASSERT_EQ(sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL), SQLITE_OK); + unresolved_query_cost_t cost = {0}; + ASSERT_EQ(sqlite3_trace_v2(db, SQLITE_TRACE_PROFILE, record_unresolved_query_cost, &cost), + SQLITE_OK); + cbm_coverage_row_t *rows = NULL; + int count = 0; + ASSERT_EQ(cbm_store_coverage_get_unresolved_path(s, project, "file-0000.js", &rows, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(rows[0].rel_path, "file-0000.js"); + cbm_store_free_coverage(rows, count); + bool found = false; + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "target.fn", &found), + CBM_STORE_OK); + ASSERT_TRUE(found); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found), + CBM_STORE_OK); + ASSERT_FALSE(found); + ASSERT_EQ(sqlite3_trace_v2(db, 0, NULL, NULL), SQLITE_OK); + ASSERT_EQ(cost.statements, 3); + ASSERT_EQ(cost.fullscan_steps, 0); + ASSERT_TRUE(cost.vm_steps < 300); + cbm_store_close(s); + PASS(); +} + +TEST(unresolved_candidate_index_tracks_replacement_pruning_and_markers) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "candidate-lifecycle"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/candidate-lifecycle"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "calls.js", "fixture", 0, 0), CBM_STORE_OK); + cbm_coverage_row_t row = {.rel_path = "calls.js", + .kind = "unresolved_calls", + .detail = "[{\"candidate\":\"old.fn\"}]"}; + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + bool found = false; + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "old.fn", &found), + CBM_STORE_OK); + ASSERT_TRUE(found); + row.detail = "[{\"candidate\":\"new.fn\"}]"; + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "old.fn", &found), + CBM_STORE_OK); + ASSERT_FALSE(found); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "new.fn", &found), + CBM_STORE_OK); + ASSERT_TRUE(found); + const char *markers[] = {"[{\"truncated\":true}]", "invalid-json", "{}", "[1]"}; + for (size_t i = 0; i < sizeof(markers) / sizeof(markers[0]); i++) { + row.detail = markers[i]; + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found), + CBM_STORE_OK); + ASSERT_TRUE(found); + } + ASSERT_EQ(cbm_store_delete_file_hash(s, project, "calls.js"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found), + CBM_STORE_OK); + ASSERT_FALSE(found); + ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "calls.js", "fixture", 0, 0), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_delete_project(s, project), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found), + CBM_STORE_OK); + ASSERT_FALSE(found); + cbm_store_close(s); + PASS(); +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(store_bulk) { + RUN_TEST(coverage_meta_has_independent_unresolved_completeness); + RUN_TEST(coverage_meta_legacy_reader_preserves_general_metadata); + RUN_TEST(coverage_meta_completeness_round_trip_and_failed_replace); + RUN_TEST(unresolved_coverage_queries_use_file_and_candidate_indexes); + RUN_TEST(unresolved_candidate_index_tracks_replacement_pruning_and_markers); RUN_TEST(bulk_pragma_wal_invariant); RUN_TEST(bulk_pragma_end_wal_invariant); #ifndef _WIN32 diff --git a/tests/test_store_graph_digest.c b/tests/test_store_graph_digest.c index abb32ba7c..489c0a813 100644 --- a/tests/test_store_graph_digest.c +++ b/tests/test_store_graph_digest.c @@ -55,9 +55,11 @@ static bool gd_int(sqlite3 *db, const char *sql, int64_t want) { return ok; } static bool gd_seed(sqlite3 *db, bool reverse, bool meta) { - static const char drop[] = "PRAGMA foreign_keys=OFF;" + static const char drop[] = + "PRAGMA foreign_keys=OFF;" "DROP TABLE IF EXISTS edges;DROP TABLE IF EXISTS nodes;DROP TABLE IF EXISTS file_hashes;" "DROP TABLE IF EXISTS project_summaries;DROP TABLE IF EXISTS lsp_surface;" + "DROP TABLE IF EXISTS index_unresolved_candidates;" "DROP TABLE IF EXISTS index_coverage;DROP TABLE IF EXISTS index_coverage_meta;" "DROP TABLE IF EXISTS projects;DROP TABLE IF EXISTS store_meta;"; if (!db || gd_sql(db,drop)!=SQLITE_OK) return false; @@ -119,6 +121,69 @@ static bool gd_cancel(void *opaque) { return c->stop || (c->armed && c->trip && c->calls>=c->trip); } +TEST(store_graph_digest_binds_unresolved_candidate_index) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_coverage_row_t row = {.rel_path = "calls.js", + .kind = "unresolved_calls", + .detail = "[{\"candidate\":\"p.target\"}]"}; + bool setup = s && cbm_store_upsert_project(s, "p", "/tmp/digest-candidates") == CBM_STORE_OK && + cbm_store_upsert_file_hash(s, "p", "calls.js", "fixture", 0, 0) == CBM_STORE_OK && + cbm_store_coverage_replace(s, "p", &row, 1) == CBM_STORE_OK; + bool candidate_before = false, candidate_after = true; + cbm_store_graph_digest_t before = {0}, after = {0}; + unsigned char content_before[CBM_STORE_GRAPH_DIGEST_BYTES] = {0}, + content_after[CBM_STORE_GRAPH_DIGEST_BYTES] = {0}; + bool read_before = + setup && + cbm_store_coverage_has_unresolved_candidate(s, "p", "p.target", &candidate_before) == + CBM_STORE_OK && + gd_p(s, &before) && cbm_store_graph_content_digest(s, "p", content_before) == CBM_STORE_OK; + /* Deliberately bypass publication/generation advancement: inbound reads this table. */ + bool removed = read_before && + gd_sql(cbm_store_get_db(s), + "DELETE FROM index_unresolved_candidates WHERE project='p'") == SQLITE_OK; + bool read_after = + removed && + cbm_store_coverage_has_unresolved_candidate(s, "p", "p.target", &candidate_after) == + CBM_STORE_OK && + gd_p(s, &after) && cbm_store_graph_content_digest(s, "p", content_after) == CBM_STORE_OK; + cbm_store_close(s); + ASSERT_TRUE(setup); + ASSERT_TRUE(read_before); + ASSERT_TRUE(candidate_before); + ASSERT_TRUE(removed); + ASSERT_TRUE(read_after); + ASSERT_FALSE(candidate_after); + ASSERT_TRUE(gd_different(&before, &after)); + ASSERT_TRUE(memcmp(content_before, content_after, sizeof(content_before)) != 0); + PASS(); +} + +TEST(store_graph_digest_candidate_index_errors_are_not_legacy_absence) { + cbm_store_t *s = gd_fixture(false, false); + unsigned char legacy[CBM_STORE_GRAPH_DIGEST_BYTES] = {0}, + again[CBM_STORE_GRAPH_DIGEST_BYTES] = {0}; + bool old = s && cbm_store_graph_content_digest(s, "p", legacy) == CBM_STORE_OK; + bool malformed = + old && gd_sql(cbm_store_get_db(s), + "CREATE TABLE index_unresolved_candidates(project TEXT,unexpected TEXT)") == + SQLITE_OK; + int content_rc = malformed ? cbm_store_graph_content_digest(s, "p", again) : CBM_STORE_OK; + bool graph_rejected = malformed && gd_reject(s, CBM_STORE_GRAPH_DIGEST_SCHEMA); + bool absent = + graph_rejected && + gd_sql(cbm_store_get_db(s), "DROP TABLE index_unresolved_candidates") == SQLITE_OK && + cbm_store_graph_content_digest(s, "p", again) == CBM_STORE_OK; + cbm_store_close(s); + ASSERT_TRUE(old); + ASSERT_TRUE(malformed); + ASSERT_EQ(content_rc, CBM_STORE_ERR); + ASSERT_TRUE(graph_rejected); + ASSERT_TRUE(absent); + ASSERT_TRUE(memcmp(legacy, again, sizeof(legacy)) == 0); + PASS(); +} + TEST(store_graph_digest_independent_canonical_golden) { cbm_store_t *s=gd_fixture(false,false); cbm_store_graph_digest_t out={0}; bool setup=s && gd_int(cbm_store_get_db(s),"SELECT count(*) FROM nodes",2) && @@ -303,6 +368,26 @@ TEST(store_graph_digest_legacy_generation_and_live_schema_compatibility) { ASSERT_TRUE(seeded);ASSERT_TRUE(compatible);PASS(); } +/* Legacy streams stay stable, while the new independent capture signal must + * be bound: changing only that value changes the digest. */ +TEST(store_graph_digest_binds_unresolved_capture_completeness) { + cbm_store_t *store = gd_fixture(false, false); + ASSERT_NOT_NULL(store); + cbm_store_graph_digest_t incomplete = {0}, complete = {0}; + ASSERT_EQ(gd_sql(cbm_store_get_db(store), + "ALTER TABLE index_coverage_meta ADD COLUMN " + "unresolved_calls_complete INTEGER NOT NULL DEFAULT 0;"), + SQLITE_OK); + ASSERT_TRUE(gd_p(store, &incomplete)); + ASSERT_EQ(gd_sql(cbm_store_get_db(store), + "UPDATE index_coverage_meta SET unresolved_calls_complete=1;"), + SQLITE_OK); + ASSERT_TRUE(gd_p(store, &complete)); + cbm_store_close(store); + ASSERT_TRUE(gd_different(&incomplete, &complete)); + PASS(); +} + TEST(store_graph_digest_schema_and_runtime_types_fail_closed) { static const char *const bad[] = { "DROP TABLE file_hashes", "DROP TABLE project_summaries", "DROP TABLE lsp_surface", @@ -595,11 +680,14 @@ TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates) { } 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); RUN_TEST(store_graph_digest_independent_canonical_golden); RUN_TEST(store_graph_digest_logical_order_and_excluded_storage_are_stable); RUN_TEST(store_graph_digest_every_known_value_and_cross_project_edge_is_bound); RUN_TEST(store_graph_digest_raw_types_project_bytes_and_schema_sql_are_distinct); RUN_TEST(store_graph_digest_legacy_generation_and_live_schema_compatibility); + RUN_TEST(store_graph_digest_binds_unresolved_capture_completeness); RUN_TEST(store_graph_digest_schema_and_runtime_types_fail_closed); RUN_TEST(store_graph_digest_wal_pins_old_content_then_observes_next_scope); RUN_TEST(store_graph_digest_exact_budgets_zero_output_and_terminal_scope); From 3455492043ed0ab5e2fc6ed30ab2f6ee3528f05b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:28:37 +0200 Subject: [PATCH 09/16] test(repro): drop external from the fast-skip invariant table Follow-up to #2161, which removes `external` from FAST_SKIP_DIRS (#2151). The repro board's invariant_discovery_fast_skip_dirs still listed it among the directories MODERATE mode must skip, so with the change the board reported "REGRESSION external not skipped in MODERATE mode". That runner is outside the gating test target, so PR CI could not see it. The table matches FAST_SKIP_DIRS again. The new behaviour stays pinned in the gating discover suite by no_skip_fast_external. Signed-off-by: Martin Vogel --- tests/repro/repro_invariant_discovery_fqn.c | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/repro/repro_invariant_discovery_fqn.c b/tests/repro/repro_invariant_discovery_fqn.c index f517de329..3ef12bb0b 100644 --- a/tests/repro/repro_invariant_discovery_fqn.c +++ b/tests/repro/repro_invariant_discovery_fqn.c @@ -288,7 +288,6 @@ TEST(invariant_discovery_fast_skip_dirs) { { "public" }, { "third_party" }, { "thirdparty" }, - { "external" }, { "migrations" }, { "build" }, /* build is in FAST_SKIP_DIRS, not ALWAYS */ { "bin" }, From 4b843c27916cd2ebe80e041df80a2cbbbcfe3838 Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Thu, 22 Oct 2026 00:44:18 +0000 Subject: [PATCH 10/16] fix(extract): YAML walkers grow their stacks instead of capping content walk_yaml_mapping (dotted key paths, 256 frames) and scan_yaml_for_infra_bindings (512 nodes) kept their pending nodes in fixed-size stacks and stopped pushing once full. A mapping with more than 256 nested sibling mappings lost the full key_path of every leaf past the cap: `k299.url` was only recorded as the shorter path that the walk of a nested block emits on its own. The infra Route pass decides on those paths. A `data:` payload block with 300 entries lost `data.svcN.webhook_url` for N >= 256, the payload deny never saw those values, and 44 of them were minted as infra Route nodes. Both walkers now start on the same-sized on-stack chunk and grow through cbm_walk_stack_reserve / cbm_walk_stack_release (#2375). If the stack cannot grow, the failure is logged as extract.walk_stack_alloc_failed and the walk stops, instead of truncating silently. Part of #2388. Signed-off-by: Gaurav Dubey --- internal/cbm/extract_unified.c | 49 +++++++++++++++----- tests/test_extraction.c | 81 ++++++++++++++++++++++++++++++++++ tests/test_lang_contract.c | 34 ++++++++++++++ 3 files changed, 152 insertions(+), 12 deletions(-) diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index fbb7e12c3..c42ccebb6 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1663,27 +1663,39 @@ typedef struct { TSNode node; const char *prefix; } yaml_walk_frame_t; -#define YAML_WALK_STACK_CAP CBM_SZ_256 +/* On-stack first chunk of the key-path walk; wider mappings spill to the heap + * through cbm_walk_stack_reserve instead of dropping frames (#2388). */ +#define YAML_WALK_STACK_INLINE CBM_SZ_256 -/* Push block_mapping children of a block_node/block_mapping value onto the walk stack. */ -static void push_yaml_block_children(TSNode val, const char *path, yaml_walk_frame_t *stack, - int *top) { +/* Push block_mapping children of a block_node/block_mapping value onto the walk + * stack, growing it as needed. False when the stack could not grow (logged by + * cbm_walk_stack_reserve as extract.walk_stack_alloc_failed). */ +static bool push_yaml_block_children(TSNode val, const char *path, yaml_walk_frame_t **stack, + int *cap, int *top, const yaml_walk_frame_t *inline_buf) { uint32_t vnc = ts_node_named_child_count(val); - for (int vi = (int)vnc - SKIP_ONE; vi >= 0 && *top < YAML_WALK_STACK_CAP; vi--) { + if (!cbm_walk_stack_reserve((void **)stack, cap, *top + (int)vnc, sizeof(yaml_walk_frame_t), + inline_buf, "yaml_key_paths")) { + return false; + } + for (int vi = (int)vnc - SKIP_ONE; vi >= 0; vi--) { TSNode vc = ts_node_named_child(val, (uint32_t)vi); const char *vctype = ts_node_type(vc); if (strcmp(vctype, "block_mapping") == 0 || strcmp(vctype, "block_mapping_pair") == 0) { - stack[(*top)++] = (yaml_walk_frame_t){vc, path}; + (*stack)[(*top)++] = (yaml_walk_frame_t){vc, path}; } } + return true; } static void walk_yaml_mapping(CBMExtractCtx *ctx, TSNode root, const char *root_prefix) { - yaml_walk_frame_t stack[YAML_WALK_STACK_CAP]; + yaml_walk_frame_t inline_stack[YAML_WALK_STACK_INLINE]; + yaml_walk_frame_t *stack = inline_stack; + int cap = YAML_WALK_STACK_INLINE; int top = 0; stack[top++] = (yaml_walk_frame_t){root, root_prefix}; + bool grown = true; - while (top > 0) { + while (grown && top > 0) { yaml_walk_frame_t frame = stack[--top]; TSNode node = frame.node; const char *prefix = frame.prefix; @@ -1709,12 +1721,16 @@ static void walk_yaml_mapping(CBMExtractCtx *ctx, TSNode root, const char *root_ } const char *vk = ts_node_type(val); if (strcmp(vk, "block_node") == 0 || strcmp(vk, "block_mapping") == 0) { - push_yaml_block_children(val, path, stack, &top); + grown = push_yaml_block_children(val, path, &stack, &cap, &top, inline_stack); + if (!grown) { + break; + } continue; } emit_yaml_leaf_value(ctx, val, path); } } + cbm_walk_stack_release(stack, inline_stack); } /* ── Infrastructure binding extraction ───────────────────────────── @@ -1873,9 +1889,13 @@ static void scan_mapping_for_bindings(CBMExtractCtx *ctx, TSNode mapping) { emit_infra_bindings(ctx, sources, source_keys, n_sources, targets, n_targets); } -#define INFRA_SCAN_STACK_CAP CBM_SZ_512 +/* On-stack first chunk of the binding scan; wider subtrees spill to the heap + * through cbm_walk_stack_reserve instead of dropping nodes (#2388). */ +#define INFRA_SCAN_STACK_INLINE CBM_SZ_512 static void scan_yaml_for_infra_bindings(CBMExtractCtx *ctx, TSNode root) { - TSNode stack[INFRA_SCAN_STACK_CAP]; + TSNode inline_stack[INFRA_SCAN_STACK_INLINE]; + TSNode *stack = inline_stack; + int cap = INFRA_SCAN_STACK_INLINE; int top = 0; stack[top++] = root; while (top > 0) { @@ -1884,10 +1904,15 @@ static void scan_yaml_for_infra_bindings(CBMExtractCtx *ctx, TSNode root) { scan_mapping_for_bindings(ctx, node); } uint32_t nc = ts_node_named_child_count(node); - for (int i = (int)nc - SKIP_ONE; i >= 0 && top < INFRA_SCAN_STACK_CAP; i--) { + if (!cbm_walk_stack_reserve((void **)&stack, &cap, top + (int)nc, sizeof(TSNode), + inline_stack, "yaml_infra_bindings")) { + break; + } + for (int i = (int)nc - SKIP_ONE; i >= 0; i--) { stack[top++] = ts_node_named_child(node, (uint32_t)i); } } + cbm_walk_stack_release(stack, inline_stack); } /* ── HCL infrastructure binding extraction ─────────────────────────── diff --git a/tests/test_extraction.c b/tests/test_extraction.c index e1c1e4491..3484852fa 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -7229,6 +7229,85 @@ TEST(body_tokens_wide_body_samples_from_the_start) { PASS(); } +/* The YAML walkers kept their pending mappings in fixed-size stacks (256 for + * the dotted key-path walk, 512 for the infrastructure-binding scan) and + * silently stopped pushing once full (#2388). A root mapping with more nested + * sibling mappings than the old cap lost the full dotted key_path of every + * leaf past it: `k299.url` was only ever recorded as the bare `url` that the + * per-mapping walk of the nested block emits. */ +enum { WIDE_YAML_KEYS = 300, WIDE_YAML_ITEMS = 600 }; + +static int has_string_ref_path(CBMFileResult *r, const char *key_path, const char *value) { + for (int i = 0; i < r->string_refs.count; i++) { + const CBMStringRef *s = &r->string_refs.items[i]; + if (s->key_path && s->value && strcmp(s->key_path, key_path) == 0 && + strcmp(s->value, value) == 0) { + return 1; + } + } + return 0; +} + +TEST(yaml_key_paths_wide_mapping_exceed_old_stack_cap) { + size_t cap = (size_t)WIDE_YAML_KEYS * 48 + 64; + char *src = malloc(cap); + ASSERT_NOT_NULL(src); + size_t pos = 0; + for (int i = 0; i < WIDE_YAML_KEYS; i++) { + pos += (size_t)snprintf(src + pos, cap - pos, + "k%d:\n url: \"https://h%d.example.com/x\"\n", i, i); + } + CBMFileResult *r = extract(src, CBM_LANG_YAML, "t", "wide.yaml"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + for (int i = 0; i < WIDE_YAML_KEYS; i++) { + char path[32]; + char value[48]; + snprintf(path, sizeof(path), "k%d.url", i); + snprintf(value, sizeof(value), "https://h%d.example.com/x", i); + ASSERT(has_string_ref_path(r, path, value)); + } + cbm_free_result(r); + free(src); + PASS(); +} + +static int has_infra_source(CBMFileResult *r, const char *source, const char *url) { + for (int i = 0; i < r->infra_bindings.count; i++) { + const CBMInfraBinding *b = &r->infra_bindings.items[i]; + if (b->source_name && b->target_url && strcmp(b->source_name, source) == 0 && + strcmp(b->target_url, url) == 0) { + return 1; + } + } + return 0; +} + +TEST(yaml_infra_bindings_wide_sequence_exceed_old_stack_cap) { + size_t cap = (size_t)WIDE_YAML_ITEMS * 64 + 64; + char *src = malloc(cap); + ASSERT_NOT_NULL(src); + size_t pos = 0; + for (int i = 0; i < WIDE_YAML_ITEMS; i++) { + pos += (size_t)snprintf(src + pos, cap - pos, + "- topic: t%d\n push_endpoint: \"https://h%d.example.com/p\"\n", i, + i); + } + CBMFileResult *r = extract(src, CBM_LANG_YAML, "t", "pubsub.yaml"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + for (int i = 0; i < WIDE_YAML_ITEMS; i++) { + char source[16]; + char url[48]; + snprintf(source, sizeof(source), "t%d", i); + snprintf(url, sizeof(url), "https://h%d.example.com/p", i); + ASSERT(has_infra_source(r, source, url)); + } + cbm_free_result(r); + free(src); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════ * Perl call-graph noise (#459 follow-up) * ═══════════════════════════════════════════════════════════════════ */ @@ -13483,6 +13562,8 @@ SUITE(extraction) { RUN_TEST(complexity_wide_body_exceeds_old_stack_cap); RUN_TEST(count_branching_wide_body_exceeds_old_stack_cap); RUN_TEST(body_tokens_wide_body_samples_from_the_start); + RUN_TEST(yaml_key_paths_wide_mapping_exceed_old_stack_cap); + RUN_TEST(yaml_infra_bindings_wide_sequence_exceed_old_stack_cap); RUN_TEST(extract_c_ifdef_split_brace_fn_recovered_issue961); RUN_TEST(extract_cpp_preproc_signature_gap_issue946); RUN_TEST(extract_cpp_preproc_macro_generated_callable_skipped_issue949); diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index 73d8c5575..ff333410b 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -1553,6 +1553,39 @@ TEST(contract_edge_infra_routes_from_deploy_configs_still_minted) { PASS(); } +/* #2388: the YAML key-path walk kept its pending mappings in a 256-frame stack + * and stopped pushing once full. A `data:` payload block with more nested + * entries than that lost the full `data..webhook_url` path of every entry + * past the cap; only the shorter per-mapping refs survived, the payload deny + * never saw them, and each of those values minted an infra Route (44 of 300 + * here). Every payload value must stay unminted however wide the block is. */ +enum { WIDE_PAYLOAD_ENTRIES = 300 }; + +TEST(contract_edge_no_infra_routes_from_wide_payload_block_issue2388) { + size_t cap = (size_t)WIDE_PAYLOAD_ENTRIES * 64 + 16; + char *src = malloc(cap); + ASSERT_NOT_NULL(src); + size_t pos = (size_t)snprintf(src, cap, "data:\n"); + for (int i = 0; i < WIDE_PAYLOAD_ENTRIES; i++) { + pos += + (size_t)snprintf(src + pos, cap - pos, + " svc%d:\n webhook_url: https://h%d.payload.example/hook\n", i, i); + } + LangProj lp; + LangFile f[] = {{"service.yaml", src}}; + cbm_store_t *store = lang_index_files(&lp, f, 1); + ASSERT_NOT_NULL(store); + int routes = count_infra_routes_matching(store, lp.project, "payload.example"); + if (routes != 0) { + fprintf(stderr, " [2388] FAIL wide payload block minted %d infra routes (expected 0)\n", + routes); + } + ASSERT_EQ(routes, 0); + lang_cleanup(&lp, store); + free(src); + PASS(); +} + /* A config URL becomes an infra Route only under a key that names an endpoint * this service exposes. Reference links are not: across 17 infra repositories * the infra Routes were changelog `pull_request` links (deckhouse 6,240), @@ -1945,6 +1978,7 @@ SUITE(lang_contract) { RUN_TEST(contract_edge_python_aliased_import_call_resolves_issue988); RUN_TEST(contract_edge_no_infra_routes_from_ci_configs_issue999); RUN_TEST(contract_edge_infra_routes_from_deploy_configs_still_minted); + RUN_TEST(contract_edge_no_infra_routes_from_wide_payload_block_issue2388); RUN_TEST(contract_edge_no_infra_routes_from_reference_links); RUN_TEST(contract_edge_infra_routes_from_endpoint_key_spellings); RUN_TEST(contract_edge_commonjs_require_call_resolves_issue871); From 24bd38c4bfce791103635db61d3aa182c195db15 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 25 Oct 2026 22:30:47 +0100 Subject: [PATCH 11/16] fix(store): bump the graph digest stream to version 2 The canonical graph digest now streams index_unresolved_candidates when the table exists and binds the unresolved_calls_complete column of index_coverage_meta, but its version word was still 1. A holder of an older digest, such as the test-impact receipt's graph_sha256, would then compare two different stream definitions under one version number. Bump CBM_STORE_GRAPH_DIGEST_VERSION to 2 and document what each version streams. The golden fixture has neither the table nor the column, so its stream differs from version 1 only in the version word: still 17 rows and 4038 framed bytes, with a new golden SHA-256, and the test now asserts version 2. Signed-off-by: Martin Vogel --- src/store/store_graph_digest.c | 3 ++- src/store/store_graph_digest.h | 11 +++++++++-- tests/test_store_graph_digest.c | 8 ++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/store/store_graph_digest.c b/src/store/store_graph_digest.c index 9cea8de80..6748f25a0 100644 --- a/src/store/store_graph_digest.c +++ b/src/store/store_graph_digest.c @@ -706,7 +706,8 @@ static bool gd_table_rows(gd_context_t *g, int index, bool present) { static bool gd_stream_tables(gd_context_t *g, const bool present[GD_TABLE_COUNT]) { for (int i = 0; i < GD_TABLE_COUNT; i++) { - /* Legacy stores retain their byte-for-byte canonical stream. */ + /* A store without the candidate table streams the same tables as + * version 1; only the version word differs. */ if (i == GD_UNRESOLVED_CANDIDATES && !present[i]) { continue; } diff --git a/src/store/store_graph_digest.h b/src/store/store_graph_digest.h index d7276fd33..957e6bd7d 100644 --- a/src/store/store_graph_digest.h +++ b/src/store/store_graph_digest.h @@ -7,7 +7,14 @@ #include #include -#define CBM_STORE_GRAPH_DIGEST_VERSION 1u +/* Canonical stream version, hashed right after the domain string. A bump + * changes every digest, so a holder of an older digest (the test-impact + * receipt's graph_sha256) sees a mismatch instead of comparing two different + * stream definitions. + * 1 The nine tables projects .. store_meta. + * 2 #2265: also index_unresolved_candidates (streamed only when present) and + * index_coverage_meta.unresolved_calls_complete (bound when present). */ +#define CBM_STORE_GRAPH_DIGEST_VERSION 2u #define CBM_STORE_GRAPH_DIGEST_BYTES 32u typedef enum { @@ -32,7 +39,7 @@ typedef struct { uint64_t framed_bytes; } cbm_store_graph_digest_t; -/* Borrow a live, exclusive D5 scope. Hash the version-1 canonical stream of +/* Borrow a live, exclusive D5 scope. Hash the version-2 canonical stream of * the nine legacy tables and the unresolved candidate index when present, * including their exact CREATE SQL and explicit project * identity. The database must use UTF-8. No new handler or transaction is used. diff --git a/tests/test_store_graph_digest.c b/tests/test_store_graph_digest.c index 489c0a813..64617a96e 100644 --- a/tests/test_store_graph_digest.c +++ b/tests/test_store_graph_digest.c @@ -38,7 +38,11 @@ static const char *const gd_inserts[] = { "INSERT INTO index_coverage(project,rel_path,kind,detail) VALUES(CAST(X'70' AS TEXT),CAST(X'62' AS TEXT),CAST(X'736b6970' AS TEXT),CAST(X'' AS TEXT));", "INSERT INTO index_coverage_meta(project,generation,index_mode,recorded_at,recording_status,ignored_files_stored,ignored_files_total,coverage_version,hash_records_complete) VALUES(CAST(X'70' AS TEXT),CAST(X'67656e' AS TEXT),CAST(X'66756c6c' AS TEXT),CAST(X'74' AS TEXT),CAST(X'636f6d706c657465' AS TEXT),-1,0,1,1);", }; -static const unsigned char gd_golden_sha[32] = { 0x46, 0x44, 0x71, 0x72, 0xdc, 0x9b, 0x2d, 0xa8, 0xda, 0xc8, 0x54, 0x5a, 0x56, 0x88, 0xab, 0x59, 0x1d, 0xad, 0x39, 0xe2, 0x9b, 0xe6, 0x33, 0x9c, 0x3d, 0x26, 0x49, 0x27, 0xd0, 0x94, 0xe9, 0x3e }; +/* Stream version 2: the oracle's version-1 stream (4038 framed bytes, golden + * 46447172dc9b2da8...) with only its version word set to 2. This fixture has + * no index_unresolved_candidates table and no unresolved_calls_complete + * column, so nothing else in its stream changes. */ +static const unsigned char gd_golden_sha[32] = { 0xd0, 0x51, 0xc4, 0x56, 0x11, 0x5c, 0xf7, 0x3c, 0x75, 0xe2, 0x89, 0xb5, 0xc3, 0x92, 0xa2, 0x56, 0xa5, 0x66, 0xe6, 0x62, 0xfd, 0x56, 0x66, 0x35, 0x05, 0xc6, 0x1e, 0xa5, 0x49, 0x7d, 0xa1, 0x30 }; #define GD_GOLDEN_ROWS UINT64_C(17) #define GD_GOLDEN_BYTES UINT64_C(4038) @@ -190,7 +194,7 @@ TEST(store_graph_digest_independent_canonical_golden) { gd_int(cbm_store_get_db(s),"SELECT length(CAST(properties AS BLOB)) FROM nodes WHERE id=-7",4) && gd_int(cbm_store_get_db(s),"SELECT count(*) FROM lsp_surface WHERE typeof(ref_bloom)='blob'",2); bool got=setup && gd_p(s,&out); cbm_store_close(s); - ASSERT_TRUE(setup); ASSERT_TRUE(got); ASSERT_EQ(out.version,1); + ASSERT_TRUE(setup); ASSERT_TRUE(got); ASSERT_EQ(out.version,2); ASSERT_TRUE(out.rows==GD_GOLDEN_ROWS); ASSERT_TRUE(out.framed_bytes==GD_GOLDEN_BYTES); ASSERT_TRUE(memcmp(out.sha256,gd_golden_sha,32)==0); PASS(); } From 8ca839363f41fe5c4ee15657d63e0291badb65f9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 25 Oct 2026 22:30:47 +0100 Subject: [PATCH 12/16] fix(store): keep unresolved_calls rows out of the missed graph The "::missed" shadow graph lists the files the indexer did not fully cover. unresolved_calls rows describe files that were indexed; their call evidence is reported by check_index_coverage and trace_path. The miss-graph builder skipped only not_indexed_* rows, so every file with an unresolved call became a missed File node: on a Prometheus checkout the shadow graph grew from 30 nodes / 29 edges to 961 / 960. A file with both a parse_partial and an unresolved_calls row also lost its parse_partial node, because rows arrive ordered by (rel_path, kind) and the File upsert keeps the later one. Treat unresolved_calls like not_indexed_* in all three places the builder filters rows: the failure fingerprint, the failure count that decides whether a shadow graph exists at all, and the per-row loop. store_coverage_shadow_excludes_unresolved_calls covers a project whose only rows are unresolved_calls (no shadow project), a file with both kinds (its parse_partial node survives), and the fingerprint (equal to the same failure rows without the evidence). Reverting each filter on its own turns its check red. Signed-off-by: Martin Vogel --- src/store/store.c | 26 +++++++--- src/store/store.h | 5 +- tests/test_store_nodes.c | 104 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 8 deletions(-) diff --git a/src/store/store.c b/src/store/store.c index 112624383..56248eeec 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -4516,6 +4516,18 @@ static void cov_json_escape(char *dst, size_t dstsz, const char *src) { * {kind, detail}. Queryable via query_graph(graph="missed") with zero * cypher-engine changes; the real project's graph gains no rows. Derived * data — rebuilt from the authoritative (post-prune) table contents. */ +/* Coverage kinds that never materialize in the miss graph. not_indexed_* + * rows are by-design exclusions, not failures. unresolved_calls rows (#2265) + * describe files that WERE indexed; their call evidence is reported by + * check_index_coverage and trace_path, and as a File row it would also + * replace a parse_partial row for the same path (rows arrive ordered by + * rel_path, kind, and the File upsert keeps the last one). */ +static bool cov_kind_outside_miss_graph(const char *kind) { + static const char by_design_prefix[] = "not_indexed"; + return strncmp(kind, by_design_prefix, sizeof(by_design_prefix) - 1) == 0 || + strcmp(kind, "unresolved_calls") == 0; +} + /* Fingerprint of the FAILURE rows the shadow graph derives from. When the * set is unchanged since the last rebuild, the wipe-and-rebuild (tens of * thousands of node/edge upserts probing the full-size nodes index -- a @@ -4527,7 +4539,7 @@ static void cov_failure_fingerprint(const cbm_coverage_row_t *rows, int count, cbm_sha256_ctx sha; cbm_sha256_init(&sha); for (int i = 0; i < count; i++) { - if (!rows[i].kind || strncmp(rows[i].kind, "not_indexed", 11) == 0) { + if (!rows[i].kind || cov_kind_outside_miss_graph(rows[i].kind)) { continue; } const char *rel = rows[i].rel_path ? rows[i].rel_path : ""; @@ -4626,11 +4638,11 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) { return CBM_STORE_OK; } /* Only FAILURE rows materialize in the miss graph; a project whose only - * coverage rows are by-design not_indexed_* entries gets NO graph (not - * even a bare root node). */ + * coverage rows are by-design not_indexed_* entries or unresolved_calls + * evidence gets NO graph (not even a bare root node). */ int failure_count = 0; for (int i = 0; i < count; i++) { - if (!rows[i].kind || strncmp(rows[i].kind, "not_indexed", 11) != 0) { + if (!rows[i].kind || !cov_kind_outside_miss_graph(rows[i].kind)) { failure_count++; } } @@ -4671,8 +4683,10 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) { } /* The missed graph shows FAILURES ("we did not manage") only — * by-design not_indexed_* rows stay out of it, so the UI's - * report-an-edge-case callout never fires for gitignored paths. */ - if (rows[i].kind && strncmp(rows[i].kind, "not_indexed", 11) == 0) { + * report-an-edge-case callout never fires for gitignored paths, and + * unresolved_calls rows stay out so a parse_partial row for the same + * file keeps its File node. */ + if (rows[i].kind && cov_kind_outside_miss_graph(rows[i].kind)) { continue; } char pathbuf[CBM_SZ_1K]; diff --git a/src/store/store.h b/src/store/store.h index 36a05d8c4..66ea2079f 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -860,10 +860,11 @@ int cbm_store_observed_lookup(cbm_store_t *s, const char *project, const char *c void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta); /* Name of the derived miss-graph shadow project ("::missed"). - * cbm_store_coverage_replace materializes the coverage rows as a file- + * cbm_store_coverage_replace materializes the coverage FAILURE rows as a file- * structure graph (Project → Folder → File{kind, detail}) under this project * name — queryable via the normal cypher path without touching the real - * project's graph. */ + * project's graph. By-design not_indexed_* rows and unresolved_calls evidence + * stay out of it. */ void cbm_store_coverage_shadow_project(char *dst, size_t dstsz, const char *project); void cbm_store_free_coverage(cbm_coverage_row_t *rows, int count); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index ccd3fc6fa..5d005c00e 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -2603,6 +2603,109 @@ TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails) { PASS(); } +/* Copy store_meta's shadow fingerprint for `project` into `out` ("" if absent). */ +static void coverage_shadow_fp(cbm_store_t *s, const char *project, char *out, size_t cap) { + out[0] = '\0'; + char key[256]; + snprintf(key, sizeof(key), "coverage_shadow_fp:%s", project); + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(cbm_store_get_db(s), "SELECT v FROM store_meta WHERE k = ?1;", -1, &stmt, + NULL) != SQLITE_OK) { + return; + } + sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT); + if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_text(stmt, 0)) { + snprintf(out, cap, "%s", (const char *)sqlite3_column_text(stmt, 0)); + } + sqlite3_finalize(stmt); +} + +/* #2265: unresolved_calls rows describe files that WERE indexed, so they stay + * out of the "::missed" graph like by-design not_indexed_* rows: + * (a) a project whose only rows are unresolved_calls gets no shadow graph, + * (b) a file with parse_partial AND unresolved_calls keeps its parse_partial + * File node (the later unresolved_calls row used to replace it), and + * (c) the shadow fingerprint ignores unresolved_calls rows, so the skip gate + * and store_meta match a project without them. */ +TEST(store_coverage_shadow_excludes_unresolved_calls) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *only = "cov-unres-only"; + const char *mixed = "cov-unres-mixed"; + const char *plain = "cov-unres-plain"; + ASSERT_EQ(cbm_store_upsert_project(s, only, "/tmp/cov-unres-only"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(s, mixed, "/tmp/cov-unres-mixed"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(s, plain, "/tmp/cov-unres-plain"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, only, "src/a.go", "", 1, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, only, "src/b.go", "", 2, 2), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, mixed, "src/both.go", "", 3, 3), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, mixed, "lib/calls.go", "", 4, 4), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, plain, "src/both.go", "", 3, 3), CBM_STORE_OK); + + /* (a) Only unresolved_calls (plus a by-design row): no shadow project. */ + cbm_coverage_row_t only_rows[] = { + {.rel_path = "src/a.go", .kind = "unresolved_calls", .detail = "[{\"candidate\":\"a.f\"}]"}, + {.rel_path = "src/b.go", .kind = "unresolved_calls", .detail = "[{\"candidate\":\"b.g\"}]"}, + {.rel_path = "vendor", .kind = "not_indexed_dir", .detail = "excluded subtree"}, + }; + ASSERT_EQ(cbm_store_coverage_replace(s, only, only_rows, 3), CBM_STORE_OK); + cbm_coverage_row_t *got = NULL; + int n = 0; + ASSERT_EQ(cbm_store_coverage_get(s, only, &got, &n), CBM_STORE_OK); + ASSERT_EQ(n, 3); /* the table keeps the evidence; only the view skips it */ + cbm_store_free_coverage(got, n); + ASSERT_EQ(cbm_store_count_nodes(s, "cov-unres-only::missed"), 0); + cbm_project_t shadow = {0}; + ASSERT_EQ(cbm_store_get_project(s, "cov-unres-only::missed", &shadow), CBM_STORE_NOT_FOUND); + cbm_project_free_fields(&shadow); + + /* (b) parse_partial + unresolved_calls on one path, and a second file + * with unresolved_calls only: the shadow holds exactly the parse_partial + * File node under src/. */ + cbm_coverage_row_t mixed_rows[] = { + {.rel_path = "src/both.go", .kind = "parse_partial", .detail = "3-4"}, + {.rel_path = "src/both.go", + .kind = "unresolved_calls", + .detail = "[{\"candidate\":\"both.h\"}]"}, + {.rel_path = "lib/calls.go", + .kind = "unresolved_calls", + .detail = "[{\"candidate\":\"calls.k\"}]"}, + }; + ASSERT_EQ(cbm_store_coverage_replace(s, mixed, mixed_rows, 3), CBM_STORE_OK); + cbm_node_t *nodes = NULL; + int nc = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, "cov-unres-mixed::missed", "File", &nodes, &nc), + CBM_STORE_OK); + ASSERT_EQ(nc, 1); + ASSERT_STR_EQ(nodes[0].file_path, "src/both.go"); + ASSERT_NOT_NULL(strstr(nodes[0].properties_json, "\"kind\":\"parse_partial\"")); + ASSERT_NOT_NULL(strstr(nodes[0].properties_json, "\"detail\":\"3-4\"")); + ASSERT_NULL(strstr(nodes[0].properties_json, "unresolved_calls")); + cbm_store_free_nodes(nodes, nc); + nodes = NULL; + nc = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, "cov-unres-mixed::missed", "Folder", &nodes, &nc), + CBM_STORE_OK); + ASSERT_EQ(nc, 1); + ASSERT_STR_EQ(nodes[0].name, "src"); + cbm_store_free_nodes(nodes, nc); + + /* (c) Same failure rows without the unresolved_calls evidence: the stored + * fingerprints match. */ + cbm_coverage_row_t plain_row = { + .rel_path = "src/both.go", .kind = "parse_partial", .detail = "3-4"}; + ASSERT_EQ(cbm_store_coverage_replace(s, plain, &plain_row, 1), CBM_STORE_OK); + char fp_mixed[128]; + char fp_plain[128]; + coverage_shadow_fp(s, mixed, fp_mixed, sizeof(fp_mixed)); + coverage_shadow_fp(s, plain, fp_plain, sizeof(fp_plain)); + ASSERT_TRUE(fp_plain[0] != '\0'); + ASSERT_STR_EQ(fp_mixed, fp_plain); + + cbm_store_close(s); + PASS(); +} + SUITE(store_nodes) { RUN_TEST(store_qn_suffix_is_literal); RUN_TEST(store_qn_suffix_is_unbounded); @@ -2612,6 +2715,7 @@ SUITE(store_nodes) { RUN_TEST(store_coverage_meta_zero_row_truncation_and_delete); 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(sql_label_allowlists_match_cbm_label_is_type_like); RUN_TEST(sql_relation_labels_match_cbm_label_is_relation); RUN_TEST(store_open_memory); From 86320afa972e4233e30a77216a6f58a6bedbd2d2 Mon Sep 17 00:00:00 2001 From: Divakant Pandey Date: Mon, 26 Oct 2026 16:06:49 +0530 Subject: [PATCH 13/16] fix(cli): emit string enums as Type.String({ enum }) in Pi adapter (#2787) Signed-off-by: Divakant Pandey --- src/cli/client_adapter.c | 40 ++++++++++++++++++-------------------- tests/test_agent_clients.c | 23 +++++++++++++++++++++- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index 83a404276..cfd2719c7 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -148,38 +148,36 @@ static void append_typebox_type(adapter_sb_t *sb, yyjson_val *val, const char *d if (type_str && strcmp(type_str, "string") == 0 && enum_val && yyjson_is_arr(enum_val) && yyjson_arr_size(enum_val) > 0) { - sb_append(sb, "Type.Union(["); size_t enum_idx = 0; size_t enum_max = yyjson_arr_size(enum_val); - bool first_enum = true; + size_t valid_count = 0; for (enum_idx = 0; enum_idx < enum_max; enum_idx++) { - yyjson_val *enum_item = yyjson_arr_get(enum_val, enum_idx); - const char *enum_str = yyjson_get_str(enum_item); - if (!enum_str) { - continue; + if (yyjson_get_str(yyjson_arr_get(enum_val, enum_idx))) { + valid_count++; } - if (!first_enum) { - sb_append(sb, ", "); - } - first_enum = false; - sb_append(sb, "Type.Literal("); - sb_append_js_string(sb, enum_str); - sb_append(sb, ")"); } - if (first_enum) { - sb_append(sb, "Type.String()"); - } else { + if (valid_count > 0) { + sb_append(sb, "Type.String({ enum: ["); + bool first_enum = true; + for (enum_idx = 0; enum_idx < enum_max; enum_idx++) { + const char *enum_str = yyjson_get_str(yyjson_arr_get(enum_val, enum_idx)); + if (!enum_str) { + continue; + } + if (!first_enum) { + sb_append(sb, ", "); + } + first_enum = false; + sb_append_js_string(sb, enum_str); + } sb_append(sb, "]"); if (include_description && description) { - sb_append(sb, ", { description: "); + sb_append(sb, ", description: "); sb_append_js_string(sb, description); - sb_append(sb, " }"); } - sb_append(sb, ")"); + sb_append(sb, " })"); return; } - sb_append(sb, ")"); - return; } const char *typebox_name = "Any"; diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index f1bc3699e..6d134a8b8 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1177,6 +1177,10 @@ TEST(client_adapter_pi_emits_parameters_and_execute) { /* TypeBox parameters schema is embedded with Type.Object syntax. */ ASSERT_NOT_NULL(strstr(js, "Type.Object(")); ASSERT_NOT_NULL(strstr(js, "Type.String(")); + /* #2787: String enums must use Type.String({ enum: [...] }) without anyOf/const */ + ASSERT_NOT_NULL(strstr(js, "Type.String({ enum: [")); + ASSERT_NULL(strstr(js, "Type.Union([Type.Literal")); + ASSERT_NULL(strstr(js, "Type.Literal(")); /* A numeric registry property must remain numeric in the generated schema. */ ASSERT_NOT_NULL(strstr(js, "depth: Type.Optional(Type.Integer(")); /* TypeBox import should be present. */ @@ -1239,7 +1243,23 @@ TEST(client_adapter_pi_wraps_result_and_honors_abort) { ASSERT_NOT_NULL( strstr(js, "[{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }]")); ASSERT_NOT_NULL(strstr(js, "signal?.addEventListener('abort', onAbort, { once: true })")); - ASSERT_NOT_NULL(strstr(js, "if (!child.killed) child.kill();")); + free(js); + PASS(); +} + +/* #2787: Pi extension must emit string enums as Type.String({ enum: [...] }) + * rather than Type.Union([Type.Literal(...)]) which TypeBox serializes as + * anyOf/const, breaking Gemini / Antigravity function declaration validation. */ +TEST(client_adapter_pi_emits_string_enum_issue2787) { + char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp"); + ASSERT_NOT_NULL(js); + ASSERT_NULL(strstr(js, "Type.Union([Type.Literal")); + ASSERT_NULL(strstr(js, "Type.Literal(")); + ASSERT_NOT_NULL(strstr(js, "Type.String({ enum: [")); + ASSERT_NOT_NULL(strstr(js, "'full'")); + ASSERT_NOT_NULL(strstr(js, "'moderate'")); + ASSERT_NOT_NULL(strstr(js, "'fast'")); + ASSERT_NOT_NULL(strstr(js, "'cross-repo-intelligence'")); free(js); PASS(); } @@ -1405,6 +1425,7 @@ SUITE(agent_clients) { RUN_TEST(client_adapter_pi_emits_parameters_and_execute); RUN_TEST(client_adapter_pi_sends_arguments_over_stdin_issue1834); RUN_TEST(client_adapter_pi_wraps_result_and_honors_abort); + RUN_TEST(client_adapter_pi_emits_string_enum_issue2787); RUN_TEST(client_adapter_escapes_windows_paths_and_quotes); RUN_TEST(client_adapter_opencode_sends_the_required_hook_event); RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction); From 402617555cd4cb56844930eb759a421922ef409c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 25 Oct 2026 21:17:24 +0100 Subject: [PATCH 14/16] feat(cli): note when a Claude Code session keeps searching without the graph The search hook gives each Grep, Glob or Bash search the matching graph symbols, but a session can keep searching text for questions the graph answers in one call (#509). Once a session has run three searches with no codebase-memory-mcp tool call, the next augmentation now adds one factual line saying so. The next note comes after six searches, then twelve, and any graph tool call resets the count. - State: a per-session counter in the process that runs hook requests (the daemon), keyed by the payload's session_id, in a bounded 64-slot table that evicts the least recently used session, behind a spin lock. Only searches that reach the graph lookup count. A search without an augmentation still counts, and the note waits for the next augmentation. - Reset: Claude's managed PostToolUse entry now matches "Read|mcp__.*codebase-memory-mcp__.*", covering plain and plugin server names. The previous Read-only entry, as installs up to v0.12 wrote it, is recognized as owned, so install replaces it in place and uninstall removes either. - Scope: Claude Code only. Codex sends the same event payload with a turn_id but has no reset hook, so its sessions are not tracked, and payloads without a session_id are not tracked either. - Cost: a graph tool call now runs one hook round-trip to the daemon for the reset, about 130 ms on a local build. Refs #509 Signed-off-by: Martin Vogel --- README.md | 7 +- src/cli/cli.c | 14 ++- src/cli/hook_augment.c | 145 +++++++++++++++++++++++- tests/test_cli.c | 248 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 401 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8fd5c68a9..c9b4a46e5 100644 --- a/README.md +++ b/README.md @@ -694,7 +694,7 @@ overwrite user-modified agents. | Agent | Activation | MCP config | Durable context / augmentation | |-------|------------|------------|--------------------------------| -| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, and post-`Read` coverage | +| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, post-`Read` coverage, and a drift note after repeated searches without a graph call | | Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart`, `PreToolUse` graph lookup for `Bash` searches | | Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` | | Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill | @@ -755,7 +755,10 @@ the requested tool call. Codex CLI's `PreToolUse` on `Bash` gives its shell searches the same context. GitHub Copilot CLI and VS Code's Copilot runtime get it after the search: their `preToolUse` can only decide permissions, so a `PostToolUse` entry for `Bash`, `Grep` and `Glob` appends the graph symbols to -the tool result. +the tool result. In Claude Code, once a session has run three searches with no +codebase-memory-mcp tool call, the next augmentation adds one line saying so; +the next note comes after six searches, then twelve. Claude's `PostToolUse` +entry also matches the graph tools, and a graph tool call resets the count. Claude Code, Codex CLI, Qwen Code, GitHub Copilot CLI, and VS Code's Copilot runtime receive paired session/subagent context where the vendor exposes a diff --git a/src/cli/cli.c b/src/cli/cli.c index ba4ae520a..95beda3f4 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -4661,7 +4661,10 @@ static int cbm_remove_grok_mcp_owned(const char *binary_path, const char *config /* Search augmentation runs before Grep/Glob/Bash; exact coverage context runs after * Read. Both adapters are context-only and fail open. */ #define CMM_HOOK_SEARCH_MATCHER "Grep|Glob|Bash" -#define CMM_HOOK_READ_MATCHER "Read" +/* The PostToolUse entry also matches the graph tools (mcp____, + * or mcp__plugin____), whose calls reset the drift + * reminder's search count (#509). */ +#define CMM_HOOK_READ_MATCHER "Read|mcp__.*codebase-memory-mcp__.*" /* Basename only; the full command path is resolved at install time via * cbm_resolve_hook_command so $CLAUDE_CONFIG_DIR is honored. */ #ifdef _WIN32 @@ -4682,6 +4685,11 @@ static const char *const cmm_claude_exec_args[] = {"hook-augment"}; /* Old matcher values from previous versions — recognized during upgrade so * upsert/remove can clean them up before inserting the current matcher. * Per-agent lists (no shared global): each caller passes its own. */ +/* Installs up to v0.12 matched only Read in PostToolUse. */ +static const char *const cmm_claude_old_read_matchers[] = { + "Read", + NULL, +}; static const char *const cmm_claude_old_matchers[] = { "Grep|Glob|Read|Search", "Grep|Glob|Read", @@ -5459,7 +5467,7 @@ static int cbm_upsert_claude_hooks_with_binary(const char *settings_path, const if (cbm_remove_previous_claude_exec_hook(settings_path, "PreToolUse", CMM_HOOK_SEARCH_MATCHER, cmm_claude_old_matchers, binary_path) != CLI_OK || cbm_remove_previous_claude_exec_hook(settings_path, "PostToolUse", CMM_HOOK_READ_MATCHER, - NULL, binary_path) != CLI_OK) { + cmm_claude_old_read_matchers, binary_path) != CLI_OK) { return CLI_ERR; } int search_result = upsert_hooks_json((hooks_upsert_args_t){ @@ -5478,6 +5486,7 @@ static int cbm_upsert_claude_hooks_with_binary(const char *settings_path, const .settings_path = settings_path, .hook_event = "PostToolUse", .matcher_str = CMM_HOOK_READ_MATCHER, + .old_matchers = cmm_claude_old_read_matchers, .command_str = command, .command_args = command_args, .command_arg_count = command_arg_count, @@ -5532,6 +5541,7 @@ static int cbm_remove_claude_hooks_with_binary(const char *settings_path, const .settings_path = settings_path, .hook_event = "PostToolUse", .matcher_str = CMM_HOOK_READ_MATCHER, + .old_matchers = cmm_claude_old_read_matchers, .old_commands = old_commands, .match_command_exact = command, .match_args = command_args, diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 822846d4a..c161f1dbf 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -26,6 +26,8 @@ #include "yyjson/yyjson.h" #include +#include +#include #include #include #include @@ -1414,6 +1416,129 @@ static void ha_tool_call_free(ha_tool_call_t *call) { call->input = NULL; } +/* Drift reminder (#509). A Claude Code session that keeps searching with Grep, + * Glob or Bash and makes no codebase-memory-mcp tool call gets one factual line + * on its next augmentation once HA_DRIFT_FIRST searches have run; the threshold + * then doubles (3, 6, 12, ...), and a graph tool call resets the session. The + * managed PostToolUse hook matches the graph tools for that reset. The state + * lives in the process that runs hook requests, which is the daemon, keyed by + * the payload's session_id in a bounded table that evicts the least recently + * used session. Codex sends the same event payload, with a turn_id, but has no + * reset hook, so its sessions are not tracked. */ +enum { + HA_DRIFT_SESSIONS = 64, + HA_DRIFT_FIRST = 3, + HA_DRIFT_SESSION_MAX = 128, +}; + +typedef struct { + char session[HA_DRIFT_SESSION_MAX + 1]; + unsigned searches; + unsigned threshold; + uint64_t used; +} ha_drift_slot_t; + +static ha_drift_slot_t g_ha_drift[HA_DRIFT_SESSIONS]; +static uint64_t g_ha_drift_clock; +static atomic_flag g_ha_drift_spin = ATOMIC_FLAG_INIT; + +static void ha_drift_lock(void) { + while (atomic_flag_test_and_set_explicit(&g_ha_drift_spin, memory_order_acquire)) {} +} + +static void ha_drift_unlock(void) { + atomic_flag_clear_explicit(&g_ha_drift_spin, memory_order_release); +} + +static const char *ha_drift_session(yyjson_val *root, ha_lifecycle_dialect_t dialect) { + if (dialect != HA_DIALECT_EVENT || !root || yyjson_obj_get(root, "turn_id")) { + return NULL; + } + const char *session = ha_obj_str(root, "session_id"); + size_t length = session ? strlen(session) : 0U; + if (length == 0U || length > HA_DRIFT_SESSION_MAX) { + return NULL; + } + for (size_t i = 0U; i < length; i++) { + unsigned char ch = (unsigned char)session[i]; + if (!isalnum(ch) && ch != '-' && ch != '_' && ch != '.') { + return NULL; + } + } + return session; +} + +/* Claude names MCP tools mcp____, and a plugin's servers + * mcp__plugin____. */ +static bool ha_is_graph_tool(const char *tool) { + return tool && strncmp(tool, "mcp__", 5) == 0 && strstr(tool, "codebase-memory-mcp__"); +} + +/* The caller holds the lock. An unknown session takes an empty slot, or else + * the least recently used one. */ +static ha_drift_slot_t *ha_drift_find(const char *session, bool create) { + ha_drift_slot_t *victim = NULL; + for (size_t i = 0U; i < HA_DRIFT_SESSIONS; i++) { + ha_drift_slot_t *slot = &g_ha_drift[i]; + if (slot->session[0] && strcmp(slot->session, session) == 0) { + slot->used = ++g_ha_drift_clock; + return slot; + } + if (!victim || (victim->session[0] && (!slot->session[0] || slot->used < victim->used))) { + victim = slot; + } + } + if (!create) { + return NULL; + } + snprintf(victim->session, sizeof(victim->session), "%s", session); + victim->searches = 0U; + victim->threshold = HA_DRIFT_FIRST; + victim->used = ++g_ha_drift_clock; + return victim; +} + +static void ha_drift_reset(const char *session) { + ha_drift_lock(); + ha_drift_slot_t *slot = ha_drift_find(session, false); + if (slot) { + memset(slot, 0, sizeof(*slot)); + } + ha_drift_unlock(); +} + +/* Counts one search. The reminder is due when the count has reached the + * threshold and this search has an augmentation to carry it; delivering it + * raises the threshold past the count by doubling. */ +static bool ha_drift_note_search(const char *session, bool augmented, unsigned *searches) { + ha_drift_lock(); + ha_drift_slot_t *slot = ha_drift_find(session, true); + if (slot->searches < UINT_MAX) { + slot->searches++; + } + bool due = augmented && slot->searches >= slot->threshold; + while (due && slot->threshold <= slot->searches && slot->threshold <= UINT_MAX / 2U) { + slot->threshold *= 2U; + } + *searches = slot->searches; + ha_drift_unlock(); + return due; +} + +/* Writes the context followed by the drift line into out. Returns false when + * they do not fit, and the caller then sends the context alone. The context is + * at most 4096 bytes plus the follow-up line, so HA_DRIFT_CONTEXT_CAP fits it. */ +enum { HA_DRIFT_CONTEXT_CAP = 5120 }; + +static bool ha_drift_context(char *out, size_t out_size, const char *context, unsigned searches) { + int written = snprintf(out, out_size, + "%s\n[codebase-memory] This session has run %u searches since its last " + "codebase-memory-mcp tool call. Questions about callers, callees, call " + "paths and definitions are one graph call each.", + context, searches); + return written > 0 && (size_t)written < out_size; +} + static bool ha_tool_event_supported(ha_lifecycle_dialect_t dialect, const char *event, const char *tool, bool *coverage) { if (coverage) { @@ -1752,6 +1877,13 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const cha ha_tool_call_read(root, forced_event, dialect, &call); const char *event = call.event; const char *tool = call.tool; + const char *session = ha_drift_session(root, dialect); + if (session && event && strcmp(event, "PostToolUse") == 0 && ha_is_graph_tool(tool)) { + ha_drift_reset(session); + ha_tool_call_free(&call); + yyjson_doc_free(doc); + return NULL; + } bool coverage = false; if (!ha_tool_event_supported(dialect, event, tool, &coverage)) { ha_tool_call_free(&call); @@ -1809,10 +1941,17 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const cha } char *ctx = ha_resolve_and_query(srv, cwd, token); + unsigned searches = 0U; + char drift_context[HA_DRIFT_CONTEXT_CAP]; + const char *text = ctx; + if (session && ha_drift_note_search(session, ctx != NULL, &searches) && + ha_drift_context(drift_context, sizeof(drift_context), ctx, searches)) { + text = drift_context; + } char *output = NULL; - if (ctx) { - output = dialect == HA_DIALECT_COPILOT ? ha_build_copilot_json(ctx) - : ha_build_event_json(event, ctx); + if (text) { + output = dialect == HA_DIALECT_COPILOT ? ha_build_copilot_json(text) + : ha_build_event_json(event, text); } free(ctx); yyjson_doc_free(doc); diff --git a/tests/test_cli.c b/tests/test_cli.c index 9853761cb..e3383be78 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -586,6 +586,10 @@ static size_t test_count_exec_hook(yyjson_val *root, const char *event_name, return matches; } +/* The Claude PostToolUse matcher: Read coverage plus the graph tools, whose + * calls reset the drift reminder. */ +#define TEST_CLAUDE_POST_MATCHER "Read|mcp__.*codebase-memory-mcp__.*" + #ifdef _WIN32 static bool test_append_command_hook(yyjson_mut_doc *doc, yyjson_mut_val *event_entries, const char *matcher, const char *command) { @@ -13559,7 +13563,8 @@ TEST(cli_claude_hook_commands_use_exec_form_with_custom_config_dir) { static const char *const matchers[] = {"startup", "resume", "clear", "compact"}; size_t owned = test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") + - test_count_exec_hook(root, "PostToolUse", "Read", binary, "hook-augment") + + test_count_exec_hook(root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary, + "hook-augment") + test_count_exec_hook(root, "SubagentStart", "*", binary, "hook-augment"); for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) { owned += test_count_exec_hook(root, "SessionStart", matchers[i], binary, "hook-augment"); @@ -14100,6 +14105,134 @@ TEST(cli_hook_augment_copilot_posttooluse_search) { PASS(); } +/* One Claude Code Grep search for the drift tests; returns whether it was + * augmented and sets *reminded when the drift line came with it. */ +static bool test_drift_search(cbm_mcp_server_t *srv, const char *session, const char *extra, + const char *pattern, bool *reminded) { + char input[640]; + snprintf(input, sizeof(input), + "{\"hook_event_name\":\"PreToolUse\",\"session_id\":\"%s\",%s\"tool_name\":\"Grep\"," + "\"cwd\":\"/tmp/hookproj\",\"tool_input\":{\"pattern\":\"%s\"}}", + session, extra, pattern); + char *output = cbm_hook_augment_process(srv, input); + *reminded = output && strstr(output, "searches since its last codebase-memory-mcp tool call"); + bool augmented = output != NULL; + free(output); + return augmented; +} + +static bool test_drift_graph_call(cbm_mcp_server_t *srv, const char *session, const char *tool) { + char input[512]; + snprintf(input, sizeof(input), + "{\"hook_event_name\":\"PostToolUse\",\"session_id\":\"%s\",\"tool_name\":\"%s\"," + "\"cwd\":\"/tmp/hookproj\",\"tool_input\":{}}", + session, tool); + char *output = cbm_hook_augment_process(srv, input); + bool silent = output == NULL; + free(output); + return silent; +} + +/* Drift reminder (#509): a Claude Code session that keeps searching without a + * graph call gets one factual line on its 3rd search, then its 6th and 12th. + * A graph tool call, by its plain or plugin name, resets the count. A search + * without an augmentation still counts and the line waits for the next one. + * Sessions are independent; Codex payloads (turn_id) and payloads without a + * session_id are never tracked. */ +TEST(cli_hook_augment_drift_reminder_counts_searches) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + char *project = cbm_project_name_from_path("/tmp/hookproj"); + ASSERT_NOT_NULL(project); + cbm_mcp_server_set_project(srv, project); + cbm_store_upsert_project(st, project, "/tmp/hookproj"); + char qualified_name[256]; + snprintf(qualified_name, sizeof(qualified_name), "%s.mod.someIndexedSymbol", project); + cbm_node_t node = {.project = project, + .label = "Function", + .name = "someIndexedSymbol", + .qualified_name = qualified_name, + .file_path = "mod.py", + .start_line = 1, + .end_line = 4}; + ASSERT_GT(cbm_store_upsert_node(st, &node), 0); + const char *hit = "someIndexedSymbol"; + bool ok = true; + bool reminded = false; + char diag[256] = ""; + + /* Reminders at 3, 6 and 12 searches; session b interleaves. */ + for (int i = 1; ok && i <= 12; i++) { + bool b_reminded = false; + if (i <= 3) { + ok = test_drift_search(srv, "drift-test-b", "", hit, &b_reminded) && + b_reminded == (i == 3); + } + ok = ok && test_drift_search(srv, "drift-test-a", "", hit, &reminded) && + reminded == (i == 3 || i == 6 || i == 12); + if (!ok) + snprintf(diag, sizeof(diag), "session a search %d reminded=%d", i, reminded); + } + /* A graph call resets; the plugin tool name resets too. */ + for (int round = 0; ok && round < 2; round++) { + const char *tool = round == 0 ? "mcp__codebase-memory-mcp__trace_path" + : "mcp__plugin_codebase-memory-mcp_codebase-memory-mcp__" + "search_graph"; + ok = test_drift_graph_call(srv, "drift-test-a", tool); + for (int i = 1; ok && i <= 3; i++) { + ok = test_drift_search(srv, "drift-test-a", "", hit, &reminded) && reminded == (i == 3); + } + if (!ok) + snprintf(diag, sizeof(diag), "reset round %d", round); + } + /* Another server's tool is no graph call: after a reset and one search it + * leaves the count alone, so the 3rd search still carries the line. */ + ok = ok && + test_drift_graph_call(srv, "drift-test-a", "mcp__codebase-memory-mcp__search_graph") && + test_drift_search(srv, "drift-test-a", "", hit, &reminded) && !reminded && + test_drift_graph_call(srv, "drift-test-a", "mcp__other__search") && + test_drift_search(srv, "drift-test-a", "", hit, &reminded) && !reminded && + test_drift_search(srv, "drift-test-a", "", hit, &reminded) && reminded; + if (!ok && !diag[0]) + snprintf(diag, sizeof(diag), "foreign tool reset the count"); + /* The 3rd search finds nothing, so the 4th carries the line; the next + * threshold is 6. */ + bool expect_c[] = {false, false, false, true, false, true}; + for (int i = 0; ok && i < 6; i++) { + bool augmented = test_drift_search(srv, "drift-test-c", "", + i == 2 ? "zzzNotIndexedAnywhere" : hit, &reminded); + ok = augmented == (i != 2) && reminded == expect_c[i]; + if (!ok) + snprintf(diag, sizeof(diag), "session c search %d augmented=%d reminded=%d", i + 1, + augmented, reminded); + } + /* Untracked: Codex, and no session_id. */ + for (int i = 1; ok && i <= 6; i++) { + ok = + test_drift_search(srv, "drift-test-codex", "\"turn_id\":\"turn-1\",", hit, &reminded) && + !reminded; + char input[512]; + snprintf(input, sizeof(input), + "{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Grep\"," + "\"cwd\":\"/tmp/hookproj\",\"tool_input\":{\"pattern\":\"%s\"}}", + hit); + char *output = cbm_hook_augment_process(srv, input); + ok = ok && output && !strstr(output, "since its last codebase-memory-mcp tool call"); + free(output); + if (!ok) + snprintf(diag, sizeof(diag), "untracked search %d", i); + } + cbm_mcp_server_free(srv); + free(project); + if (!ok) { + fprintf(stderr, "drift diag: %s\n", diag); + FAIL("drift reminder must fire at 3, 6, 12 searches and reset on a graph call"); + } + PASS(); +} + /* The hook-client fast path answers Copilot's PostToolUse before identity * hashing when the augmenter could never respond: a shell command that is not * a search, or a tool the augmenter does not read (VS Code's local hook parser @@ -14761,7 +14894,8 @@ TEST(cli_upgrade_migrates_released_claude_hook_scripts) { size_t registered = test_count_exec_hook(settings_root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") + - test_count_exec_hook(settings_root, "PostToolUse", "Read", binary, "hook-augment") + + test_count_exec_hook(settings_root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary, + "hook-augment") + test_count_exec_hook(settings_root, "SubagentStart", "*", binary, "hook-augment"); for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) { registered += test_count_exec_hook(settings_root, "SessionStart", matchers[i], binary, @@ -16006,7 +16140,7 @@ TEST(cli_upsert_claude_hook_fresh) { ASSERT(strstr(data, "PreToolUse") != NULL); ASSERT(strstr(data, "PostToolUse") != NULL); ASSERT(strstr(data, "\"Grep|Glob|Bash\"") != NULL); - ASSERT(strstr(data, "\"Read\"") != NULL); + ASSERT(strstr(data, "\"" TEST_CLAUDE_POST_MATCHER "\"") != NULL); ASSERT(strstr(data, "\"Grep|Glob|Read\"") == NULL); ASSERT_EQ(test_count_substring(data, "cbm-code-discovery-gate"), 2U); @@ -16478,7 +16612,8 @@ TEST(cli_claude_hooks_use_exec_form_across_shells_issue1733) { size_t owned = test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary_path, "hook-augment") + - test_count_exec_hook(root, "PostToolUse", "Read", binary_path, "hook-augment") + + test_count_exec_hook(root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary_path, + "hook-augment") + test_count_exec_hook(root, "SubagentStart", "*", binary_path, "hook-augment"); for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) { owned += test_count_exec_hook(root, "SessionStart", matchers[i], binary_path, @@ -16542,7 +16677,8 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) { size_t before_owned = test_count_exec_hook(installed_root, "PreToolUse", "Grep|Glob|Bash", binary_path, "hook-augment") + - test_count_exec_hook(installed_root, "PostToolUse", "Read", binary_path, "hook-augment") + + test_count_exec_hook(installed_root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary_path, + "hook-augment") + test_count_exec_hook(installed_root, "SubagentStart", "*", binary_path, "hook-augment"); for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) { before_owned += test_count_exec_hook(installed_root, "SessionStart", matchers[i], @@ -16576,6 +16712,104 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) { PASS(); } +/* Rewrite the current Claude PostToolUse matcher back to the previous "Read". */ +static bool test_claude_settings_to_read_matcher(const char *settings_path) { + char *text = read_test_file_alloc(settings_path); + const char *found = text ? strstr(text, TEST_CLAUDE_POST_MATCHER) : NULL; + if (!found) { + free(text); + return false; + } + size_t prefix = (size_t)(found - text); + size_t total = strlen(text) - strlen(TEST_CLAUDE_POST_MATCHER) + strlen("Read") + 1U; + char *rewritten = malloc(total); + if (!rewritten) { + free(text); + return false; + } + snprintf(rewritten, total, "%.*sRead%s", (int)prefix, text, + found + strlen(TEST_CLAUDE_POST_MATCHER)); + write_test_file(settings_path, rewritten); + free(rewritten); + free(text); + return true; +} + +/* The Claude PostToolUse hook also matches the graph tools, whose calls reset + * the drift reminder (#509). An install that finds the previous Read-only + * entry replaces it with one current entry, and uninstall removes the + * previous one too. */ +TEST(cli_claude_post_hook_matcher_migrates_from_read) { + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_config = save_test_env("CLAUDE_CONFIG_DIR"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-read-matcher-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char config_dir[512]; + char settings_path[640]; + char install_dir[512]; + char binary_path[640]; + snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir); + snprintf(install_dir, sizeof(install_dir), "%s/bin", tmpdir); +#ifdef _WIN32 + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp.exe", install_dir); +#else + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp", install_dir); +#endif + test_mkdirp(config_dir); + test_mkdirp(install_dir); + test_mark_claude_code_installed(tmpdir); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + + size_t counts[4] = {0U, 0U, 0U, 0U}; + int install_rc[2] = {-1, -1}; + for (int round = 0; round < 2; round++) { + install_rc[round] = cbm_install_agent_configs(tmpdir, binary_path, false, false); + char *settings = read_test_file_alloc(settings_path); + yyjson_doc *doc = settings ? yyjson_read(settings, strlen(settings), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + counts[round * 2] = test_count_exec_hook(root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, + binary_path, "hook-augment"); + counts[round * 2 + 1] = + test_count_exec_hook(root, "PostToolUse", "Read", binary_path, "hook-augment"); + yyjson_doc_free(doc); + free(settings); + if (round == 0 && !test_claude_settings_to_read_matcher(settings_path)) + break; + } + bool rewritten = test_claude_settings_to_read_matcher(settings_path); + char dir_arg[640]; + snprintf(dir_arg, sizeof(dir_arg), "--dir=%s", install_dir); + char *argv[] = {"uninstall", "--yes", dir_arg}; + int uninstall_rc = cli_test_cmd_uninstall(3, argv); + char *uninstalled = read_test_file_alloc(settings_path); + size_t after_owned = test_count_substring(uninstalled ? uninstalled : "", "\"hook-augment\""); + free(uninstalled); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CLAUDE_CONFIG_DIR", saved_config); + test_rmdir_r(tmpdir); + if (install_rc[0] != 0 || install_rc[1] != 0 || counts[0] != 1U || counts[1] != 0U || + counts[2] != 1U || counts[3] != 0U || !rewritten || uninstall_rc != 0 || + after_owned != 0U) { + fprintf(stderr, + "Claude matcher diag install=%d,%d current=%zu,%zu read=%zu,%zu rewritten=%d " + "uninstall=%d after=%zu\n", + install_rc[0], install_rc[1], counts[0], counts[2], counts[1], counts[3], rewritten, + uninstall_rc, after_owned); + FAIL("Claude install must migrate the Read-only PostToolUse hook, and uninstall must " + "remove it"); + } + PASS(); +} + /* issue #618: hook-augment was a structural no-op on Windows because its path * guards required POSIX-style '/'-prefixed absolute paths, so a drive-letter * cwd (C:/repo) was rejected before any search_graph query. The predicate must @@ -16941,7 +17175,7 @@ TEST(cli_upsert_claude_hook_existing) { ASSERT_NOT_NULL(data); ASSERT(strstr(data, "\"Grep|Glob|Bash\"") != NULL); ASSERT(strstr(data, "PostToolUse") != NULL); - ASSERT(strstr(data, "\"Read\"") != NULL); + ASSERT(strstr(data, "\"" TEST_CLAUDE_POST_MATCHER "\"") != NULL); /* Existing hook preserved */ ASSERT(strstr(data, "Bash") != NULL); ASSERT(strstr(data, "firewall") != NULL); @@ -19084,6 +19318,7 @@ SUITE(cli) { RUN_TEST(cli_hook_augment_codex_pretooluse_payload); RUN_TEST(cli_hook_augment_copilot_posttooluse_search); RUN_TEST(cli_hook_augment_copilot_posttooluse_noop_gate); + RUN_TEST(cli_hook_augment_drift_reminder_counts_searches); RUN_TEST(cli_hook_augment_bash_pattern_extractor); RUN_TEST(cli_hook_augment_coverage_requests_machine_json); RUN_TEST(cli_hook_augment_lifecycle_output_contract); @@ -19169,6 +19404,7 @@ SUITE(cli) { RUN_TEST(cli_windows_claude_hook_command_is_shell_portable); RUN_TEST(cli_claude_hooks_use_exec_form_across_shells_issue1733); RUN_TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733); + RUN_TEST(cli_claude_post_hook_matcher_migrates_from_read); RUN_TEST(cli_hook_augment_path_is_abs); RUN_TEST(cli_hook_augment_deadline_breadcrumb_issue858); RUN_TEST(cli_hook_augment_log_long_home_creates_no_cut_path); From 8fd93a2e08bb10aa8d9943e0043e1a6f03439958 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 19:32:04 +0100 Subject: [PATCH 15/16] test(smoke): pin the Claude PostToolUse matcher that covers the graph tools Smoke check 8d required Claude's PostToolUse matcher to be exactly Read. The drift note widens it on purpose to Read|mcp__.*codebase-memory-mcp__.*, so a graph tool call can reset the search count, and the macOS PR smoke failed on it. 8d now requires exactly the new matcher, and besides a Search matcher it also refuses a catch-all ("*" or empty) matcher, as its comment promised. Checked against real installs: the previous binary's Read-only settings fail 8d, the new install passes, and the new install plus a catch-all PostToolUse entry fails. Refs #509 Signed-off-by: Martin Vogel --- scripts/smoke-test.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index c1a3f3ec7..2a894ca55 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1697,7 +1697,8 @@ echo "OK 8c-i: Claude exact-tool graph subagent" # 8d: Claude Code hooks keep search augmentation and read-coverage reporting # separate: PreToolUse matches exactly Grep|Glob|Bash, while PostToolUse matches -# exactly Read. Neither hook may grow a Search or catch-all matcher. +# exactly Read plus the graph tools, whose calls reset the drift note. Neither +# hook may grow a Search or catch-all matcher. if ! cat "$FAKE_HOME/.claude/settings.json" 2>/dev/null | python3 -c " import json, sys d = json.load(sys.stdin) @@ -1705,14 +1706,15 @@ all_hooks = d.get('hooks', {}) pre = all_hooks.get('PreToolUse', []) post = all_hooks.get('PostToolUse', []) ok = (any(h.get('matcher') == 'Grep|Glob|Bash' for h in pre) and - any(h.get('matcher') == 'Read' for h in post)) -bad = any('Search' in str(h.get('matcher', '')) for h in pre + post) + any(h.get('matcher') == 'Read|mcp__.*codebase-memory-mcp__.*' for h in post)) +bad = any('Search' in str(h.get('matcher', '')) or h.get('matcher', '') in ('', '*') + for h in pre + post) sys.exit(0 if (ok and not bad) else 1) " 2>/dev/null; then echo "FAIL 8d: Claude search/read hook matchers are not exact" exit 1 fi -echo "OK 8d: Claude Code PreToolUse Grep|Glob|Bash + PostToolUse Read" +echo "OK 8d: Claude Code PreToolUse Grep|Glob|Bash + PostToolUse Read and graph tools" # 8e: Claude Code shim script — must be non-blocking augmenter, not a gate. # #929: Windows installs a .cmd script (extensionless bash shims triggered the From 74982efc60bb4b5ca49a2efdf7698f0f98991517 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 23:41:04 +0100 Subject: [PATCH 16/16] test(cli): restore Pi abort-kill assertion dropped alongside #2787 test The #2787 change added client_adapter_pi_emits_string_enum_issue2787 directly below client_adapter_pi_wraps_result_and_honors_abort and, in the same hunk, removed that test's check that the generated abort handler kills the cli child (if (!child.killed) child.kill();). The generator still emits the line; restore the assertion byte-for-byte so the abort path keeps its coverage. Signed-off-by: Martin Vogel --- tests/test_agent_clients.c | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 6d134a8b8..0d57723a0 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1243,6 +1243,7 @@ TEST(client_adapter_pi_wraps_result_and_honors_abort) { ASSERT_NOT_NULL( strstr(js, "[{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }]")); ASSERT_NOT_NULL(strstr(js, "signal?.addEventListener('abort', onAbort, { once: true })")); + ASSERT_NOT_NULL(strstr(js, "if (!child.killed) child.kill();")); free(js); PASS(); }