From 2c71746fa52f4945f7e2edcbd92e5ded91bff395 Mon Sep 17 00:00:00 2001 From: Pcristin Date: Thu, 24 Sep 2026 14:40:36 +0000 Subject: [PATCH 1/5] 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 78b8dccf96..f3e700aeab 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 c808cb1b47..9d12c55d9d 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 154518658d..f0953764e3 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 e7782adeff..182241ecad 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 558f974d24..5b1936dbac 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 b6ce844f75..82489255e3 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 60649f5054..02cb9db016 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 568740f170..9a072cd83c 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 2/5] 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 1b5d3f3ef9..9b49a3fe48 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 f3e700aeab..a8acb0b290 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 84958954cf..e8257f8802 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 e85ad015f1..e081c3c3af 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 9d12c55d9d..3e6952490e 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); } +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); + } + 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); + } + 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 (unresolved_call_is_missing(rc) && !unresolved_collect_site(capture, rc)) { + return false; + } + } + 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 || result->resolved_calls.count == 0 || - p->unresolved_capture_failed) { + if (!p || !rel_path || !result || p->unresolved_capture_failed) { return; } - yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - if (!doc) { - p->unresolved_capture_failed = true; + if (!unresolved_has_missing_calls(result)) { 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); + 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 f0953764e3..9d34ff241b 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 82489255e3..2163eed09b 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 02cb9db016..2f8b41eb25 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 9a072cd83c..fd8ddd127f 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 3/5] 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 a8acb0b290..cf2b276cbc 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 2f8b41eb25..f91fd68970 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 e632d4214bf5ca100ddbd27f303eb013ce9c105e Mon Sep 17 00:00:00 2001 From: Pcristin Date: Wed, 7 Oct 2026 11:00:47 +0000 Subject: [PATCH 4/5] 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 60f3c799fd..be3a1af709 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 5/5] 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 | 281 ++++++++++++++++------- src/pipeline/pipeline_incremental.c | 61 +++-- src/pipeline/pipeline_internal.h | 11 +- src/store/store.c | 280 +++++++++++++++++------ 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, 1629 insertions(+), 474 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 6d16708ec0..5d723c1182 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 121d2696de..426dec44c5 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 1514c3c688..6b5cb5cb34 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 efb217e95f..9fb41d37c4 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 3359841b19..e9089b1909 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 b34db716cc..05cd8e4cd9 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 be3a1af709..34a579e13f 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); -} - -enum { UNRESOLVED_KEY_OVERHEAD = 128, UNRESOLVED_INITIAL_ROWS = 64, UNRESOLVED_ROW_GROWTH = 2 }; + cbm_free(CBM_MEM_CLASS_RESOLVE, ptr); +} + +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; + return UNRESOLVED_JSON_RAW_CHAR_BYTES; +} + +/* 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; } - unresolved_carrier_t *carrier = cbm_alloc(CBM_MEM_CLASS_DUMP, sizeof(*carrier)); - if (!carrier) { - cbm_free(CBM_MEM_CLASS_DUMP, key); - return false; + 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; } - 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 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(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])) { +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 df6453c504..a6451f4b8e 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 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p 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 f818bf3075..95f26b6876 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 996d71cc77..47ffdea85f 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; - - 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_index_unresolved_candidates(s, project) != CBM_STORE_OK) { + (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 de82075c7c..959245c7fc 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 c9287adeea..ff15aa33e8 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 9baac3c394..9cea8de808 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 004ed7840d..d7276fd33e 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 0f7a900ba3..ab3e412fee 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 859f295f0a..6d16917edd 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 826cef87ee..e7991b7ac4 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 abb32ba7c3..489c0a8131 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);