diff --git a/src/foundation/mem_core.c b/src/foundation/mem_core.c index 121d2696d..426dec44c 100644 --- a/src/foundation/mem_core.c +++ b/src/foundation/mem_core.c @@ -67,7 +67,7 @@ static mem_class_stats_t g_classes[CBM_MEM_CLASS_COUNT]; static const char *const g_class_names[CBM_MEM_CLASS_COUNT] = { "other", "gbuf_node", "gbuf_edge", "gbuf_string", "gbuf_index", "extract", "arena", - "ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array", + "ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array", "resolve", }; const char *cbm_mem_class_name(cbm_mem_class_t cls) { diff --git a/src/foundation/mem_core.h b/src/foundation/mem_core.h index 1514c3c68..6b5cb5cb3 100644 --- a/src/foundation/mem_core.h +++ b/src/foundation/mem_core.h @@ -66,6 +66,7 @@ typedef enum { CBM_MEM_CLASS_STORE, /* SQLite (bound mem methods) + store batches and row buffers */ CBM_MEM_CLASS_HASH_TABLE, /* CBMHashTable buckets/entries not claimed by an owner class */ CBM_MEM_CLASS_DYN_ARRAY, /* CBM_DYN_ARRAY item storage (every cbm_da_* user) */ + CBM_MEM_CLASS_RESOLVE, /* call resolution evidence and coverage capture */ CBM_MEM_CLASS_COUNT } cbm_mem_class_t; diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index a096016ce..5e98ea176 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -71,6 +71,7 @@ enum { #include "watcher/watcher.h" #include "foundation/mem.h" #include "foundation/mem_core.h" +#include "foundation/hash_table.h" #include "foundation/arena.h" #include "foundation/diagnostics.h" #include "foundation/platform.h" @@ -6234,11 +6235,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; @@ -6268,6 +6271,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); @@ -6325,6 +6333,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); @@ -6362,7 +6376,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, " @@ -6370,7 +6384,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."); } } @@ -6582,6 +6598,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, @@ -6596,26 +6649,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 == 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; } } } @@ -6635,6 +6676,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. */ @@ -6718,6 +6762,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); @@ -9169,6 +9215,207 @@ 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 { + cbm_store_t *store; + const char *project; + 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 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 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)) && + trace_coverage_may_target_project(match, leaf)) { + match->unresolved_out = true; + } +} + +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 = 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); + } + } + if (detail) { + yyjson_doc_free(detail); + } +} + +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 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]); + } + 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; + } + cbm_ht_foreach(match->callers, trace_coverage_query_file, &query); + cbm_ht_free(query.files); + } + 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) { + 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.unresolved_calls_complete; + 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 = {.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) && + trace_coverage_build_sets(&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); + 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); @@ -9505,6 +9752,27 @@ 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; + 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"); + render_trace_output:; int rows_left = row_target; out_len = requested_out_len < rows_left ? requested_out_len : rows_left; @@ -9593,7 +9861,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); @@ -9604,7 +9872,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); @@ -9670,8 +9938,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, @@ -9680,8 +9947,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, @@ -9739,13 +10005,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) { @@ -9770,12 +10034,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) { @@ -10278,11 +10541,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 @@ -10292,17 +10556,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); } @@ -10313,7 +10580,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 0d3fdf1cd..73ea8f0f1 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, const 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,7 +412,7 @@ 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); + 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_handler(ctx->registry, call->second_arg_name, module_qn, imp_keys, @@ -423,6 +429,7 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca } } } + return emitted; } /* Emit an HTTP/async route edge for a service call. */ @@ -512,7 +519,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, +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); @@ -524,10 +531,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); } - cbm_gbuf_insert_edge(gbuf, src, tgt, type, props); + 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, const 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) { @@ -548,7 +556,7 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const 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); @@ -557,8 +565,8 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const 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 = @@ -589,6 +597,7 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const 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. */ @@ -596,20 +605,18 @@ 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 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]; @@ -621,10 +628,10 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const 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); @@ -633,7 +640,7 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const 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. */ @@ -655,11 +662,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) { @@ -671,35 +819,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 = 1; - /* 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 @@ -735,23 +857,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 @@ -814,31 +926,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 @@ -915,16 +1004,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); @@ -934,24 +1016,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 @@ -964,16 +1032,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); } - 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; } @@ -1124,12 +1189,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; @@ -1148,19 +1216,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 fef5e4f54..58188c0c7 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2204,7 +2204,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, +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); @@ -2220,7 +2220,8 @@ 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); + 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. */ @@ -2296,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, +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); @@ -2311,14 +2312,14 @@ 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, +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, @@ -2342,7 +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); - cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props); + 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_handler(registry, handler_ref, module_qn, ik, iv, ic, main_gbuf); @@ -2358,6 +2359,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. */ @@ -2636,7 +2638,7 @@ static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons * 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, +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, @@ -2667,10 +2669,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 */ } @@ -2680,6 +2681,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); @@ -2693,7 +2695,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 @@ -2703,6 +2705,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, @@ -2715,24 +2718,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, +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. */ @@ -2979,10 +2984,31 @@ static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, const CB : 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, @@ -3058,7 +3084,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; } @@ -3282,9 +3310,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; } } @@ -3309,9 +3337,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 @@ -3371,8 +3399,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) { @@ -3388,18 +3417,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; } + 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++; @@ -3874,6 +3904,30 @@ 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; + (void)cbm_pipeline_test_extraction_ok(rc->pctx, rc->files[file_idx].language, 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, 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 * publishes owners for the structs its own file declares; a struct from a * header is known only to the cross-file pass. A member access that still has @@ -3956,17 +4010,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; - (void)cbm_pipeline_test_extraction_ok(rc->pctx, rc->files[file_idx].language, 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); @@ -3979,6 +4023,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 @@ -4036,6 +4081,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, NULL); continue; } @@ -4164,9 +4210,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); @@ -4194,6 +4243,11 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { memory_order_relaxed); resolve_file_cleanup: + /* 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(); cbm_registry_resolve_cache_end(); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index e18b41fc6..34a579e13 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" @@ -41,7 +42,13 @@ 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" +/* C11 guarantees stdbool.h; keep yyjson's Boolean types aligned with ours. */ +#ifndef YYJSON_HAS_STDBOOL_H +#define YYJSON_HAS_STDBOOL_H 1 +#endif +#include "yyjson/yyjson.h" #include #include @@ -266,6 +273,13 @@ 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; + 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. */ void (*before_publish_hook)(cbm_pipeline_t *, const char *, void *); @@ -621,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); @@ -829,6 +845,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; @@ -907,6 +925,392 @@ 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_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_RESOLVE, ptr, size); +} + +static void unresolved_json_free(void *ctx, void *ptr) { + (void)ctx; + 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_RESOLVE, 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 { + int call_index; +} unresolved_carrier_t; + +typedef struct { + yyjson_mut_doc *doc; + 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_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_RESOLVE, (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 size_t unresolved_json_char_bytes(unsigned char ch) { + if (ch < UNRESOLVED_JSON_CONTROL_BOUNDARY) { + return UNRESOLVED_JSON_CONTROL_ESCAPE_BYTES; + } + if (ch == '"' || ch == '\\') { + return UNRESOLVED_JSON_ESCAPE_BYTES; + } + 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; + } + return bytes; +} + +static size_t unresolved_site_json_bytes(const CBMResolvedCall *rc, const char *caller, + const char *leaf, const char *candidate) { + const char *fields[] = {caller, leaf, rc->reason ? rc->reason : "unresolved", candidate}; + size_t bytes = UNRESOLVED_SITE_OVERHEAD; + for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) { + size_t field_bytes = unresolved_string_json_bytes(fields[i]); + if (field_bytes > UNRESOLVED_MAX_JSON_BYTES - bytes) { + return UNRESOLVED_JSON_OVERSIZE; + } + bytes += field_bytes; + } + return bytes; +} + +/* Hash only the bounded diagnostic subset. Hashing every extracted call + * would retain unbounded side state. */ +static bool unresolved_select_diagnostics(unresolved_capture_t *capture) { + size_t selected_bytes = UNRESOLVED_JSON_ARRAY_BYTES + UNRESOLVED_TRUNCATION_RESERVE; + const CBMFileResult *result = capture->result; + for (int i = 0; i < result->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &result->resolved_calls.items[i]; + if (!unresolved_call_is_missing(rc)) { + continue; + } + const char *leaf = cbm_lsp_bare_segment(rc->callee_qn); + size_t site_bytes = unresolved_site_json_bytes(rc, rc->caller_qn, leaf, NULL); + if (capture->selected_count == UNRESOLVED_MAX_SITES || + site_bytes > UNRESOLVED_MAX_JSON_BYTES - selected_bytes) { + capture->truncated = true; + break; + } + selected_bytes += site_bytes; + capture->selected[capture->selected_count++] = rc; + char *key = unresolved_site_key("", leaf, rc->site_start_byte, rc->site_end_byte, + rc->source_origin); + if (!key) { + return false; + } + if (cbm_ht_has(capture->carriers, key)) { + cbm_free(CBM_MEM_CLASS_RESOLVE, key); + continue; + } + unresolved_carrier_t *carrier = cbm_alloc(CBM_MEM_CLASS_RESOLVE, sizeof(*carrier)); + if (!carrier) { + cbm_free(CBM_MEM_CLASS_RESOLVE, key); + return false; + } + carrier->call_index = UNRESOLVED_NO_CARRIER; + cbm_ht_set(capture->carriers, key, carrier); + if (!cbm_ht_has(capture->carriers, key)) { + cbm_free(CBM_MEM_CLASS_RESOLVE, key); + cbm_free(CBM_MEM_CLASS_RESOLVE, carrier); + return false; + } + } + return true; +} + +static bool unresolved_build_carriers(unresolved_capture_t *capture) { + if (!unresolved_select_diagnostics(capture)) { + return false; + } + const CBMFileResult *result = capture->result; + /* Caller names in the TS LSP walk can denote an outer factory. Join on + * span, leaf and origin; the extractor owns the enclosing function name. */ + for (int i = 0; i < result->calls.count && capture->selected_count > 0; i++) { + const CBMCall *call = &result->calls.items[i]; + if (!call->callee_name || call->site_end_byte <= call->site_start_byte) { + continue; + } + const char *leaf = cbm_lsp_bare_segment(call->callee_name); + if (unresolved_string_json_bytes(leaf) > UNRESOLVED_MAX_JSON_BYTES) { + continue; /* No bounded diagnostic key can match this leaf. */ + } + char *key = unresolved_site_key("", leaf, call->site_start_byte, call->site_end_byte, + call->source_origin); + if (!key) { + return false; + } + unresolved_carrier_t *carrier = cbm_ht_get(capture->carriers, key); + cbm_free(CBM_MEM_CLASS_RESOLVE, key); + if (carrier) { + /* An ambiguous occurrence must never erase a diagnostic. */ + carrier->call_index = + carrier->call_index == UNRESOLVED_NO_CARRIER ? i : UNRESOLVED_AMBIGUOUS_CARRIER; + } + } + return true; +} + +static bool unresolved_append_site(unresolved_capture_t *capture, const CBMResolvedCall *rc, + const CBMCall *call, const 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 = + 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 && 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); +} + +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); + 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_RESOLVE, key); + return true; + } + cbm_ht_set(capture->seen, key, key); + if (!cbm_ht_has(capture->seen, key)) { + cbm_free(CBM_MEM_CLASS_RESOLVE, key); + return false; + } + return unresolved_append_site(capture, rc, call, evidence, caller, leaf); +} + +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; +} + +/* 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_RESOLVE, 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, + const CBMCallEvidence *evidence) { + if (!p || !rel_path || !result || p->unresolved_capture_failed) { + return; + } + if (!unresolved_has_missing_calls(result)) { + return; + } + yyjson_alc allocator = {.malloc = unresolved_json_alloc, + .realloc = unresolved_json_realloc, + .free = unresolved_json_free}; + unresolved_capture_t capture = {.doc = yyjson_mut_doc_new(&allocator), + .carriers = cbm_ht_create_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) && 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_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_RESOLVE, detail); + cbm_free(CBM_MEM_CLASS_RESOLVE, path); + } + } + if (!ok) { + p->unresolved_capture_failed = true; + } + unresolved_capture_free(&capture); +} + +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; +} + static void pipeline_release_test_config(cbm_pipeline_t *p) { if (!p) { return; @@ -950,6 +1354,12 @@ 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_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_RESOLVE, p->unresolved_rows); + cbm_mutex_destroy(&p->unresolved_mutex); cbm_git_context_free(&p->git_ctx); /* gbuf, store, registry freed during/after run */ /* Defensive owner cleanup; normal runs release after publication. */ @@ -3166,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) { @@ -3223,7 +3638,8 @@ 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; @@ -3231,6 +3647,9 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas cov = malloc((size_t)cov_total * sizeof(*cov)); if (cov) { coverage_rows_available = true; + 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, @@ -3269,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 b60f8bdec..a6451f4b8 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -31,6 +31,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 @@ -2376,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 @@ -2392,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); @@ -2689,7 +2715,13 @@ 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; + 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; if (cov_cap > 0) { cov = (cbm_coverage_row_t *)malloc((size_t)cov_cap * sizeof(*cov)); @@ -2709,6 +2741,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"; @@ -2794,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, @@ -2909,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); @@ -3012,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. */ @@ -3055,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; @@ -3276,11 +3315,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 = 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++) { @@ -3300,6 +3345,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"; @@ -3365,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 7133d8a23..dabc5ebc6 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1234,7 +1234,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 = 5 }; typedef struct { cbm_gbuf_t *gbuf; @@ -1319,6 +1319,18 @@ 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 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); /* 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 846a34447..6d1c99f1d 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -345,6 +345,20 @@ 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);" + /* 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. */ @@ -357,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); @@ -365,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 @@ -4277,6 +4319,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) { @@ -4374,72 +4524,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 @@ -4561,6 +4653,43 @@ 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_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 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) { if (!meta) { return; @@ -4585,10 +4714,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); @@ -4602,6 +4741,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 3cea85b90..264d84e92 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -716,7 +716,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 { @@ -725,9 +727,15 @@ typedef struct { const char *detail; } cbm_coverage_row_t; +/* 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 * "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; @@ -739,6 +747,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 @@ -767,6 +776,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 bounded unresolved invocation diagnostics for one exact caller file. */ +int cbm_store_coverage_get_unresolved_path(cbm_store_t *s, const char *project, + const char *rel_path, cbm_coverage_row_t **out, + int *count); +/* Indexed candidate existence; truncated/malformed evidence also returns true + * because omitted candidates cannot prove an exact inbound total. */ +int cbm_store_coverage_has_unresolved_candidate(cbm_store_t *s, const char *project, + const char *candidate, bool *out); /* Fetch/free the metadata paired with the current coverage row set. */ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out); diff --git a/src/store/store_content_digest.c b/src/store/store_content_digest.c index c9287adee..ff15aa33e 100644 --- a/src/store/store_content_digest.c +++ b/src/store/store_content_digest.c @@ -22,6 +22,8 @@ #include #include +enum { SC_SQL_TEXT_LENGTH = -1 }; + typedef struct { const char *name; const char *sql; /* ?1 = project; every column is hashed, rows in this order */ @@ -44,11 +46,15 @@ static const sc_query_t sc_queries[] = { "main.lsp_surface WHERE project=?1 ORDER BY 1,2,3,4,5;"}, {"index_coverage", "SELECT rel_path,kind,detail FROM main.index_coverage WHERE project=?1 " "ORDER BY 1,2,3;"}, - {"index_coverage_meta", "SELECT index_mode,recording_status,ignored_files_stored," - "ignored_files_total,coverage_version,hash_records_complete FROM " - "main.index_coverage_meta WHERE project=?1;"}, + {"index_coverage_meta", + "SELECT index_mode,recording_status,ignored_files_stored," + "ignored_files_total,coverage_version,hash_records_complete,unresolved_calls_complete FROM " + "main.index_coverage_meta WHERE project=?1;"}, {"project_summaries", "SELECT summary,source_hash FROM main.project_summaries WHERE " "project=?1;"}, + {"index_unresolved_candidates", "SELECT candidate,rel_path,kind FROM " + "main.index_unresolved_candidates WHERE project=?1 " + "ORDER BY 1,2,3;"}, }; /* What test selection reads: node identity and every edge by its endpoints @@ -84,7 +90,31 @@ static void sc_frame(cbm_sha256_ctx *ctx, unsigned char tag, const void *bytes, static bool sc_table(sqlite3 *db, const sc_query_t *q, const char *project, cbm_sha256_ctx *ctx) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(db, q->sql, -1, &st, NULL) != SQLITE_OK) { - return false; + sqlite3_finalize(st); + st = NULL; + if (strcmp(q->name, "index_unresolved_candidates") == 0) { + /* Omit the optional frame only for proven legacy absence. Errors + * reading a present candidate index must invalidate the digest. */ + sqlite3_stmt *schema = NULL; + bool absent = + sqlite3_prepare_v2( + db, + "SELECT 1 FROM main.sqlite_schema WHERE name='index_unresolved_candidates' " + "COLLATE NOCASE LIMIT 1;", + SC_SQL_TEXT_LENGTH, &schema, NULL) == SQLITE_OK && + sqlite3_step(schema) == SQLITE_DONE; + return sqlite3_finalize(schema) == SQLITE_OK && absent; + } + /* Read-only legacy databases retain their existing content digest. */ + if (strcmp(q->name, "index_coverage_meta") != 0 || + sqlite3_prepare_v2(db, + "SELECT index_mode,recording_status,ignored_files_stored," + "ignored_files_total,coverage_version,hash_records_complete FROM " + "main.index_coverage_meta WHERE project=?1;", + SC_SQL_TEXT_LENGTH, &st, NULL) != SQLITE_OK) { + sqlite3_finalize(st); + return false; + } } bool ok = sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT) == SQLITE_OK; sc_frame(ctx, 'T', q->name, strlen(q->name)); diff --git a/src/store/store_graph_digest.c b/src/store/store_graph_digest.c index 9baac3c39..9cea8de80 100644 --- a/src/store/store_graph_digest.c +++ b/src/store/store_graph_digest.c @@ -16,9 +16,14 @@ enum { GD_ALLOW_INTEGER = 1 << GD_INTEGER, GD_ALLOW_TEXT = 1 << GD_TEXT, GD_ALLOW_BLOB = 1 << GD_BLOB, - GD_TABLE_COUNT = 9, - GD_MAX_COLUMNS = 9, + GD_TABLE_COUNT = 10, + GD_MAX_COLUMNS = 10, + GD_COVERAGE_META = 7, GD_STORE_META = 8, + GD_UNRESOLVED_CANDIDATES = 9, + GD_SCHEMA_DECLARED_TYPE = 2, + GD_SCHEMA_PRIMARY_KEY = 5, + GD_SCHEMA_HIDDEN = 6, GD_CHUNK_BYTES = 65536 }; @@ -87,9 +92,15 @@ static const gd_column_t gd_index_coverage_meta[] = {{"project", GD_T, 0, 1}, {"ignored_files_stored", GD_I, 0, 0}, {"ignored_files_total", GD_I, 0, 0}, {"coverage_version", GD_I, 0, 0}, - {"hash_records_complete", GD_I, 0, 0}}; + {"hash_records_complete", GD_I, 0, 0}, + {"unresolved_calls_complete", GD_I, 0, 0}}; static const gd_column_t gd_store_meta[] = {{"k", GD_T, 0, 1}, {"v", GD_T, 0, 0}}; +static const gd_column_t gd_unresolved_candidates[] = {{"project", GD_T, 0, 1}, + {"candidate", GD_T, 0, 2}, + {"rel_path", GD_T, 0, 3}, + {"kind", GD_T, 0, 0}}; + /* These descriptors and SQL strings are immutable; all other state is local. */ static const gd_table_t gd_tables[GD_TABLE_COUNT] = { {"projects", gd_projects, GD_COLUMNS(gd_projects), "PRAGMA main.table_xinfo('projects');", @@ -116,14 +127,28 @@ static const gd_table_t gd_tables[GD_TABLE_COUNT] = { "PRAGMA main.table_xinfo('index_coverage');", "SELECT project,rel_path,kind,detail FROM main.index_coverage " "ORDER BY CAST(project AS BLOB) ASC,CAST(rel_path AS BLOB) ASC,CAST(kind AS BLOB) ASC;"}, - {"index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta), + {"index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta) - 1, "PRAGMA main.table_xinfo('index_coverage_meta');", "SELECT project,generation,index_mode,recorded_at,recording_status,ignored_files_stored," "ignored_files_total,coverage_version,hash_records_complete FROM main.index_coverage_meta " "ORDER BY CAST(project AS BLOB) ASC;"}, {"store_meta", gd_store_meta, GD_COLUMNS(gd_store_meta), "PRAGMA main.table_xinfo('store_meta');", - "SELECT k,v FROM main.store_meta ORDER BY CAST(k AS BLOB) ASC;"}}; + "SELECT k,v FROM main.store_meta ORDER BY CAST(k AS BLOB) ASC;"}, + {"index_unresolved_candidates", gd_unresolved_candidates, GD_COLUMNS(gd_unresolved_candidates), + "PRAGMA main.table_xinfo('index_unresolved_candidates');", + "SELECT project,candidate,rel_path,kind FROM main.index_unresolved_candidates " + "ORDER BY CAST(project AS BLOB) ASC,CAST(candidate AS BLOB) ASC," + "CAST(rel_path AS BLOB) ASC,CAST(kind AS BLOB) ASC;"}}; + +/* Preserve the legacy canonical stream; newer metadata binds the independent + * capture completeness column as well. Both schemas are checked exactly. */ +static const gd_table_t gd_coverage_meta_current = { + "index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta), + "PRAGMA main.table_xinfo('index_coverage_meta');", + "SELECT project,generation,index_mode,recorded_at,recording_status,ignored_files_stored," + "ignored_files_total,coverage_version,hash_records_complete,unresolved_calls_complete " + "FROM main.index_coverage_meta ORDER BY CAST(project AS BLOB) ASC;"}; #undef GD_B #undef GD_N @@ -142,8 +167,14 @@ typedef struct { uint64_t bytes; bool have_uid; bool have_counter; + bool have_unresolved_completeness; } gd_context_t; +static const gd_table_t *gd_table(const gd_context_t *g, int index) { + return index == GD_COVERAGE_META && g->have_unresolved_completeness ? &gd_coverage_meta_current + : &gd_tables[index]; +} + typedef struct { const unsigned char *data; size_t length; @@ -408,8 +439,30 @@ static bool gd_table_kinds(gd_context_t *g, bool present[GD_TABLE_COUNT]) { return rc == SQLITE_DONE && gd_finalize(g); } +static bool gd_column_schema(gd_context_t *g, const gd_column_t *expected) { + if (sqlite3_column_type(g->stmt, GD_SCHEMA_PRIMARY_KEY) != SQLITE_INTEGER || + sqlite3_column_type(g->stmt, GD_SCHEMA_HIDDEN) != SQLITE_INTEGER || + sqlite3_column_int64(g->stmt, GD_SCHEMA_PRIMARY_KEY) != expected->primary_key || + sqlite3_column_int64(g->stmt, GD_SCHEMA_HIDDEN) != expected->kind) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + if (expected->primary_key && expected->types == GD_ALLOW_INTEGER) { + gd_text_t declared_type; + if (!gd_read_text(g, GD_SCHEMA_DECLARED_TYPE, &declared_type)) { + return false; + } + if (!gd_text_ascii_eq(declared_type, "INTEGER")) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + } + return true; +} + static bool gd_columns(gd_context_t *g, const gd_table_t *table) { bool seen[GD_MAX_COLUMNS] = {false}; + bool coverage_meta = table == &gd_tables[GD_COVERAGE_META]; + int allowed_columns = + coverage_meta ? gd_coverage_meta_current.column_count : table->column_count; if (!gd_prepare(g, table->column_query)) { return false; } @@ -424,37 +477,30 @@ static bool gd_columns(gd_context_t *g, const gd_table_t *table) { return false; } int column = -1; - for (int i = 0; i < table->column_count; i++) { + for (int i = 0; i < allowed_columns; i++) { if (gd_text_eq(name, table->columns[i].name)) { column = i; break; } } - if (column < 0 || seen[column] || count == table->column_count || - sqlite3_column_type(g->stmt, 5) != SQLITE_INTEGER || - sqlite3_column_type(g->stmt, 6) != SQLITE_INTEGER) { + if (column < 0 || seen[column] || count == allowed_columns || + !gd_column_schema(g, &table->columns[column])) { return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); } - const gd_column_t *expected = &table->columns[column]; - if (sqlite3_column_int64(g->stmt, 5) != expected->primary_key || - sqlite3_column_int64(g->stmt, 6) != expected->kind) { - return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); - } - if (expected->primary_key && expected->types == GD_ALLOW_INTEGER) { - gd_text_t declared_type; - if (!gd_read_text(g, 2, &declared_type)) { - return false; - } - if (!gd_text_ascii_eq(declared_type, "INTEGER")) { - return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); - } - } seen[column] = true; count++; } - if (rc != SQLITE_DONE || count != table->column_count) { + if (rc != SQLITE_DONE || count < table->column_count) { return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); } + for (int i = 0; i < table->column_count; i++) { + if (!seen[i]) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + } + if (coverage_meta) { + g->have_unresolved_completeness = seen[table->column_count]; + } return gd_finalize(g); } @@ -481,8 +527,21 @@ static bool gd_project(gd_context_t *g, const unsigned char *project, size_t len return rc == SQLITE_DONE && gd_finalize(g); } +static bool gd_validate_schema(gd_context_t *g, bool present[GD_TABLE_COUNT], + const unsigned char *project, size_t length) { + if (!gd_encoding(g) || !gd_table_kinds(g, present)) { + return false; + } + for (int i = 0; i < GD_TABLE_COUNT; i++) { + if (present[i] && !gd_columns(g, &gd_tables[i])) { + return false; + } + } + return gd_project(g, project, length); +} + static bool gd_table_header(gd_context_t *g, int index, bool present) { - const gd_table_t *table = &gd_tables[index]; + const gd_table_t *table = gd_table(g, index); if (!gd_u8(g, 0x20) || !gd_u32(g, (uint32_t)index) || !gd_literal(g, table->name) || !gd_u32(g, (uint32_t)table->column_count)) { return false; @@ -605,7 +664,7 @@ static bool gd_column_value(gd_context_t *g, int index, const gd_column_t *colum static bool gd_table_rows(gd_context_t *g, int index, bool present) { uint64_t rows = 0; - const gd_table_t *table = &gd_tables[index]; + const gd_table_t *table = gd_table(g, index); if (present) { if (!gd_prepare(g, table->row_query)) { return false; @@ -645,6 +704,19 @@ static bool gd_table_rows(gd_context_t *g, int index, bool present) { return gd_u8(g, 0x22) && gd_u64(g, rows); } +static bool gd_stream_tables(gd_context_t *g, const bool present[GD_TABLE_COUNT]) { + for (int i = 0; i < GD_TABLE_COUNT; i++) { + /* Legacy stores retain their byte-for-byte canonical stream. */ + if (i == GD_UNRESOLVED_CANDIDATES && !present[i]) { + continue; + } + if (!gd_table_header(g, i, present[i]) || !gd_table_rows(g, i, present[i])) { + return false; + } + } + return true; +} + static bool gd_project_argument(gd_context_t *g, const unsigned char *project, size_t length) { while (length) { size_t chunk = length > GD_CHUNK_BYTES ? GD_CHUNK_BYTES : length; @@ -694,15 +766,7 @@ cbm_store_graph_digest_status_t cbm_store_graph_digest( return g.status; } bool present[GD_TABLE_COUNT] = {false}; - if (!gd_encoding(&g) || !gd_table_kinds(&g, present)) { - goto done; - } - for (int i = 0; i < GD_TABLE_COUNT; i++) { - if (present[i] && !gd_columns(&g, &gd_tables[i])) { - goto done; - } - } - if (!gd_project(&g, project, project_len)) { + if (!gd_validate_schema(&g, present, project, project_len)) { goto done; } cbm_sha256_init(&g.sha); @@ -711,10 +775,8 @@ cbm_store_graph_digest_status_t cbm_store_graph_digest( !gd_u8(&g, 0x10) || !gd_value(&g, GD_TEXT, project, project_len)) { goto done; } - for (int i = 0; i < GD_TABLE_COUNT; i++) { - if (!gd_table_header(&g, i, present[i]) || !gd_table_rows(&g, i, present[i])) { - goto done; - } + if (!gd_stream_tables(&g, present)) { + goto done; } if (!gd_u8(&g, 0x7f) || !gd_u64(&g, g.rows) || !gd_check(&g)) { goto done; diff --git a/src/store/store_graph_digest.h b/src/store/store_graph_digest.h index 004ed7840..d7276fd33 100644 --- a/src/store/store_graph_digest.h +++ b/src/store/store_graph_digest.h @@ -33,7 +33,8 @@ typedef struct { } cbm_store_graph_digest_t; /* Borrow a live, exclusive D5 scope. Hash the version-1 canonical stream of - * all nine known tables, including their exact CREATE SQL and explicit project + * the nine legacy tables and the unresolved candidate index when present, + * including their exact CREATE SQL and explicit project * identity. The database must use UTF-8. No new handler or transaction is used. * Independent scopes/connections may run concurrently; no mutable state is * shared. The callback/connection restrictions of D5 continue to apply. diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 174950de4..0ecca3c2f 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -6979,6 +6979,14 @@ 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, + .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, .label = "Function", @@ -7324,6 +7332,14 @@ 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, + .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, .label = "Function", .name = "hub", @@ -7814,6 +7830,344 @@ 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); + 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,\"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", + .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); + + 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); + /* 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\":" + "\"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(); +} + +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, + .unresolved_calls_complete = true, + .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"; + 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 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", + 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- @@ -23168,6 +23522,42 @@ 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(); +} + /* ── search_code: every match resolves inside the project root ───────── * search_code returns the lines its scan opened. The scoped file list is * built from the index, and an indexed path that now passes through a @@ -23459,6 +23849,7 @@ TEST(search_code_drops_walker_hits_that_resolve_outside_root) { } 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); @@ -23651,6 +24042,11 @@ 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); 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 481738fe8..6d16917ed 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -765,6 +765,259 @@ 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(); +} + +/* 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); +} +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)); + 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"); @@ -5057,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 @@ -18407,6 +18735,11 @@ 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_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); RUN_TEST(pipeline_branch_root_structure); RUN_TEST(pipeline_project_name_derived); @@ -18792,6 +19125,7 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_source_addition_before_publication_preserves_previous_generation); RUN_TEST(pipeline_tsconfig_mutation_before_publication_preserves_previous_generation); RUN_TEST(pipeline_exact_inputs_migrate_coverage_metadata_and_index_mode); + RUN_TEST(pipeline_capture_failure_preserves_general_coverage_and_rebuilds); RUN_TEST(pipeline_existing_artifact_refreshes_after_default_forced_full_reindex); RUN_TEST(pipeline_full_cancel_after_predump_preserves_previous_generation); RUN_TEST(pipeline_full_cancel_after_destination_prepare_preserves_previous_generation); diff --git a/tests/test_store_bulk.c b/tests/test_store_bulk.c index 826cef87e..e7991b7ac 100644 --- a/tests/test_store_bulk.c +++ b/tests/test_store_bulk.c @@ -167,9 +167,265 @@ TEST(bulk_crash_recovery) { } #endif /* _WIN32 */ +TEST(coverage_meta_has_independent_unresolved_completeness) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + sqlite3_stmt *stmt = NULL; + ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(s), + "SELECT unresolved_calls_complete FROM index_coverage_meta;", -1, + &stmt, NULL), + SQLITE_OK); + sqlite3_finalize(stmt); + cbm_store_close(s); + PASS(); +} + +TEST(coverage_meta_legacy_reader_preserves_general_metadata) { + char path[256]; + make_temp_path(path, sizeof(path)); + cleanup_db(path); + cbm_store_t *writer = cbm_store_open_path(path); + ASSERT_NOT_NULL(writer); + ASSERT_EQ(cbm_store_upsert_project(writer, "legacy-coverage", "/tmp/legacy-coverage"), + CBM_STORE_OK); + ASSERT_EQ( + sqlite3_exec(cbm_store_get_db(writer), + "DROP TABLE index_coverage_meta;" + "CREATE TABLE index_coverage_meta(project TEXT PRIMARY KEY,generation TEXT," + "index_mode TEXT,recorded_at TEXT,recording_status TEXT," + "ignored_files_stored INTEGER,ignored_files_total INTEGER," + "coverage_version INTEGER,hash_records_complete INTEGER);" + "INSERT INTO index_coverage_meta VALUES('legacy-coverage','generation','full'," + "'recorded','truncated',1,2,5,1);", + NULL, NULL, NULL), + SQLITE_OK); + cbm_store_close(writer); + cbm_store_t *reader = cbm_store_open_path_query(path); + ASSERT_NOT_NULL(reader); + cbm_coverage_meta_t meta = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(reader, "legacy-coverage", &meta), CBM_STORE_OK); + ASSERT_STR_EQ(meta.recording_status, "truncated"); + ASSERT_EQ(meta.ignored_files_stored, 1); + ASSERT_EQ(meta.ignored_files_total, 2); + ASSERT_EQ(meta.coverage_version, 5); + ASSERT_TRUE(meta.hash_records_complete); + ASSERT_FALSE(meta.unresolved_calls_complete); + cbm_store_coverage_meta_clear(&meta); + cbm_store_close(reader); + writer = cbm_store_open_path(path); + ASSERT_NOT_NULL(writer); + sqlite3_stmt *stmt = NULL; + ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(writer), + "SELECT unresolved_calls_complete FROM index_coverage_meta;", -1, + &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_EQ(sqlite3_column_int(stmt, 0), 0); + sqlite3_finalize(stmt); + ASSERT_EQ(cbm_store_coverage_meta_get(writer, "legacy-coverage", &meta), CBM_STORE_OK); + ASSERT_STR_EQ(meta.recording_status, "truncated"); + ASSERT_FALSE(meta.unresolved_calls_complete); + cbm_store_coverage_meta_clear(&meta); + cbm_store_close(writer); + cleanup_db(path); + PASS(); +} + +TEST(coverage_meta_completeness_round_trip_and_failed_replace) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "capture-meta"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/capture-meta"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "gap.js", "fixture", 0, 0), CBM_STORE_OK); + cbm_coverage_row_t row = {.rel_path = "gap.js", .kind = "parse_partial", .detail = "2-3"}; + cbm_coverage_meta_t meta = {.generation = "baseline", + .index_mode = "full", + .recording_status = "complete", + .coverage_version = 5, + .hash_records_complete = true, + .unresolved_calls_complete = true}; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, &meta), CBM_STORE_OK); + cbm_coverage_meta_t fetched = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK); + ASSERT_TRUE(fetched.unresolved_calls_complete); + cbm_store_coverage_meta_clear(&fetched); + ASSERT_EQ( + sqlite3_exec(cbm_store_get_db(s), + "CREATE TRIGGER reject_capture_meta BEFORE UPDATE ON index_coverage_meta " + "BEGIN SELECT RAISE(ABORT, 'metadata fault'); END;", + NULL, NULL, NULL), + SQLITE_OK); + meta.generation = "failed"; + meta.unresolved_calls_complete = false; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, NULL, 0, &meta), CBM_STORE_ERR); + ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK); + ASSERT_STR_EQ(fetched.generation, "baseline"); + ASSERT_TRUE(fetched.unresolved_calls_complete); + cbm_store_coverage_meta_clear(&fetched); + cbm_coverage_row_t *rows = NULL; + int count = 0; + ASSERT_EQ(cbm_store_coverage_get(s, project, &rows, &count), CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(rows[0].kind, "parse_partial"); + cbm_store_free_coverage(rows, count); + ASSERT_EQ( + sqlite3_exec(cbm_store_get_db(s), "DROP TRIGGER reject_capture_meta;", NULL, NULL, NULL), + SQLITE_OK); + ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, &meta), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK); + ASSERT_FALSE(fetched.unresolved_calls_complete); + ASSERT_STR_EQ(fetched.recording_status, "complete"); + cbm_store_coverage_meta_clear(&fetched); + ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, NULL), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_NOT_FOUND); + cbm_store_close(s); + PASS(); +} + +typedef struct { + int statements; + int fullscan_steps; + int vm_steps; +} unresolved_query_cost_t; + +static int record_unresolved_query_cost(unsigned type, void *userdata, void *statement, + void *elapsed) { + (void)elapsed; + unresolved_query_cost_t *cost = userdata; + sqlite3_stmt *stmt = statement; + const char *sql = sqlite3_sql(stmt); + if (type == SQLITE_TRACE_PROFILE && sql && + (strstr(sql, "FROM index_unresolved_candidates") || + strstr(sql, "AND kind = 'unresolved_calls'"))) { + cost->statements++; + cost->fullscan_steps += sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_FULLSCAN_STEP, 0); + cost->vm_steps += sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_VM_STEP, 0); + } + return 0; +} + +TEST(unresolved_coverage_queries_use_file_and_candidate_indexes) { + enum { UNRELATED_FILES = 2000 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "bounded-lookups"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/bounded-lookups"), CBM_STORE_OK); + sqlite3 *db = cbm_store_get_db(s); + ASSERT_EQ(sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL), SQLITE_OK); + sqlite3_stmt *coverage = NULL; + sqlite3_stmt *candidate = NULL; + ASSERT_EQ(sqlite3_prepare_v2(db, + "INSERT INTO index_coverage(project,rel_path,kind,detail) " + "VALUES(?1,?2,'unresolved_calls',?3);", + -1, &coverage, NULL), + SQLITE_OK); + ASSERT_EQ( + sqlite3_prepare_v2(db, + "INSERT INTO index_unresolved_candidates(project,candidate,rel_path) " + "VALUES(?1,?2,?3);", + -1, &candidate, NULL), + SQLITE_OK); + for (int i = 0; i <= UNRELATED_FILES; i++) { + char path[64]; + snprintf(path, sizeof(path), "file-%04d.js", i); + ASSERT_EQ(sqlite3_bind_text(coverage, 1, project, -1, SQLITE_STATIC), SQLITE_OK); + ASSERT_EQ(sqlite3_bind_text(coverage, 2, path, -1, SQLITE_TRANSIENT), SQLITE_OK); + ASSERT_EQ(sqlite3_bind_text(coverage, 3, + i == 0 ? "[{\"candidate\":\"target.fn\"}]" + : "[{\"candidate\":\"other.fn\"}]", + -1, SQLITE_STATIC), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(coverage), SQLITE_DONE); + ASSERT_EQ(sqlite3_reset(coverage), SQLITE_OK); + ASSERT_EQ(sqlite3_bind_text(candidate, 1, project, -1, SQLITE_STATIC), SQLITE_OK); + ASSERT_EQ( + sqlite3_bind_text(candidate, 2, i == 0 ? "target.fn" : "other.fn", -1, SQLITE_STATIC), + SQLITE_OK); + ASSERT_EQ(sqlite3_bind_text(candidate, 3, path, -1, SQLITE_TRANSIENT), SQLITE_OK); + ASSERT_EQ(sqlite3_step(candidate), SQLITE_DONE); + ASSERT_EQ(sqlite3_reset(candidate), SQLITE_OK); + } + sqlite3_finalize(coverage); + sqlite3_finalize(candidate); + ASSERT_EQ(sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL), SQLITE_OK); + unresolved_query_cost_t cost = {0}; + ASSERT_EQ(sqlite3_trace_v2(db, SQLITE_TRACE_PROFILE, record_unresolved_query_cost, &cost), + SQLITE_OK); + cbm_coverage_row_t *rows = NULL; + int count = 0; + ASSERT_EQ(cbm_store_coverage_get_unresolved_path(s, project, "file-0000.js", &rows, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(rows[0].rel_path, "file-0000.js"); + cbm_store_free_coverage(rows, count); + bool found = false; + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "target.fn", &found), + CBM_STORE_OK); + ASSERT_TRUE(found); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found), + CBM_STORE_OK); + ASSERT_FALSE(found); + ASSERT_EQ(sqlite3_trace_v2(db, 0, NULL, NULL), SQLITE_OK); + ASSERT_EQ(cost.statements, 3); + ASSERT_EQ(cost.fullscan_steps, 0); + ASSERT_TRUE(cost.vm_steps < 300); + cbm_store_close(s); + PASS(); +} + +TEST(unresolved_candidate_index_tracks_replacement_pruning_and_markers) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "candidate-lifecycle"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/candidate-lifecycle"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "calls.js", "fixture", 0, 0), CBM_STORE_OK); + cbm_coverage_row_t row = {.rel_path = "calls.js", + .kind = "unresolved_calls", + .detail = "[{\"candidate\":\"old.fn\"}]"}; + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + bool found = false; + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "old.fn", &found), + CBM_STORE_OK); + ASSERT_TRUE(found); + row.detail = "[{\"candidate\":\"new.fn\"}]"; + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "old.fn", &found), + CBM_STORE_OK); + ASSERT_FALSE(found); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "new.fn", &found), + CBM_STORE_OK); + ASSERT_TRUE(found); + const char *markers[] = {"[{\"truncated\":true}]", "invalid-json", "{}", "[1]"}; + for (size_t i = 0; i < sizeof(markers) / sizeof(markers[0]); i++) { + row.detail = markers[i]; + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found), + CBM_STORE_OK); + ASSERT_TRUE(found); + } + ASSERT_EQ(cbm_store_delete_file_hash(s, project, "calls.js"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found), + CBM_STORE_OK); + ASSERT_FALSE(found); + ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "calls.js", "fixture", 0, 0), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_delete_project(s, project), CBM_STORE_OK); + ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found), + CBM_STORE_OK); + ASSERT_FALSE(found); + cbm_store_close(s); + PASS(); +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(store_bulk) { + RUN_TEST(coverage_meta_has_independent_unresolved_completeness); + RUN_TEST(coverage_meta_legacy_reader_preserves_general_metadata); + RUN_TEST(coverage_meta_completeness_round_trip_and_failed_replace); + RUN_TEST(unresolved_coverage_queries_use_file_and_candidate_indexes); + RUN_TEST(unresolved_candidate_index_tracks_replacement_pruning_and_markers); RUN_TEST(bulk_pragma_wal_invariant); RUN_TEST(bulk_pragma_end_wal_invariant); #ifndef _WIN32 diff --git a/tests/test_store_graph_digest.c b/tests/test_store_graph_digest.c index abb32ba7c..489c0a813 100644 --- a/tests/test_store_graph_digest.c +++ b/tests/test_store_graph_digest.c @@ -55,9 +55,11 @@ static bool gd_int(sqlite3 *db, const char *sql, int64_t want) { return ok; } static bool gd_seed(sqlite3 *db, bool reverse, bool meta) { - static const char drop[] = "PRAGMA foreign_keys=OFF;" + static const char drop[] = + "PRAGMA foreign_keys=OFF;" "DROP TABLE IF EXISTS edges;DROP TABLE IF EXISTS nodes;DROP TABLE IF EXISTS file_hashes;" "DROP TABLE IF EXISTS project_summaries;DROP TABLE IF EXISTS lsp_surface;" + "DROP TABLE IF EXISTS index_unresolved_candidates;" "DROP TABLE IF EXISTS index_coverage;DROP TABLE IF EXISTS index_coverage_meta;" "DROP TABLE IF EXISTS projects;DROP TABLE IF EXISTS store_meta;"; if (!db || gd_sql(db,drop)!=SQLITE_OK) return false; @@ -119,6 +121,69 @@ static bool gd_cancel(void *opaque) { return c->stop || (c->armed && c->trip && c->calls>=c->trip); } +TEST(store_graph_digest_binds_unresolved_candidate_index) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_coverage_row_t row = {.rel_path = "calls.js", + .kind = "unresolved_calls", + .detail = "[{\"candidate\":\"p.target\"}]"}; + bool setup = s && cbm_store_upsert_project(s, "p", "/tmp/digest-candidates") == CBM_STORE_OK && + cbm_store_upsert_file_hash(s, "p", "calls.js", "fixture", 0, 0) == CBM_STORE_OK && + cbm_store_coverage_replace(s, "p", &row, 1) == CBM_STORE_OK; + bool candidate_before = false, candidate_after = true; + cbm_store_graph_digest_t before = {0}, after = {0}; + unsigned char content_before[CBM_STORE_GRAPH_DIGEST_BYTES] = {0}, + content_after[CBM_STORE_GRAPH_DIGEST_BYTES] = {0}; + bool read_before = + setup && + cbm_store_coverage_has_unresolved_candidate(s, "p", "p.target", &candidate_before) == + CBM_STORE_OK && + gd_p(s, &before) && cbm_store_graph_content_digest(s, "p", content_before) == CBM_STORE_OK; + /* Deliberately bypass publication/generation advancement: inbound reads this table. */ + bool removed = read_before && + gd_sql(cbm_store_get_db(s), + "DELETE FROM index_unresolved_candidates WHERE project='p'") == SQLITE_OK; + bool read_after = + removed && + cbm_store_coverage_has_unresolved_candidate(s, "p", "p.target", &candidate_after) == + CBM_STORE_OK && + gd_p(s, &after) && cbm_store_graph_content_digest(s, "p", content_after) == CBM_STORE_OK; + cbm_store_close(s); + ASSERT_TRUE(setup); + ASSERT_TRUE(read_before); + ASSERT_TRUE(candidate_before); + ASSERT_TRUE(removed); + ASSERT_TRUE(read_after); + ASSERT_FALSE(candidate_after); + ASSERT_TRUE(gd_different(&before, &after)); + ASSERT_TRUE(memcmp(content_before, content_after, sizeof(content_before)) != 0); + PASS(); +} + +TEST(store_graph_digest_candidate_index_errors_are_not_legacy_absence) { + cbm_store_t *s = gd_fixture(false, false); + unsigned char legacy[CBM_STORE_GRAPH_DIGEST_BYTES] = {0}, + again[CBM_STORE_GRAPH_DIGEST_BYTES] = {0}; + bool old = s && cbm_store_graph_content_digest(s, "p", legacy) == CBM_STORE_OK; + bool malformed = + old && gd_sql(cbm_store_get_db(s), + "CREATE TABLE index_unresolved_candidates(project TEXT,unexpected TEXT)") == + SQLITE_OK; + int content_rc = malformed ? cbm_store_graph_content_digest(s, "p", again) : CBM_STORE_OK; + bool graph_rejected = malformed && gd_reject(s, CBM_STORE_GRAPH_DIGEST_SCHEMA); + bool absent = + graph_rejected && + gd_sql(cbm_store_get_db(s), "DROP TABLE index_unresolved_candidates") == SQLITE_OK && + cbm_store_graph_content_digest(s, "p", again) == CBM_STORE_OK; + cbm_store_close(s); + ASSERT_TRUE(old); + ASSERT_TRUE(malformed); + ASSERT_EQ(content_rc, CBM_STORE_ERR); + ASSERT_TRUE(graph_rejected); + ASSERT_TRUE(absent); + ASSERT_TRUE(memcmp(legacy, again, sizeof(legacy)) == 0); + PASS(); +} + TEST(store_graph_digest_independent_canonical_golden) { cbm_store_t *s=gd_fixture(false,false); cbm_store_graph_digest_t out={0}; bool setup=s && gd_int(cbm_store_get_db(s),"SELECT count(*) FROM nodes",2) && @@ -303,6 +368,26 @@ TEST(store_graph_digest_legacy_generation_and_live_schema_compatibility) { ASSERT_TRUE(seeded);ASSERT_TRUE(compatible);PASS(); } +/* Legacy streams stay stable, while the new independent capture signal must + * be bound: changing only that value changes the digest. */ +TEST(store_graph_digest_binds_unresolved_capture_completeness) { + cbm_store_t *store = gd_fixture(false, false); + ASSERT_NOT_NULL(store); + cbm_store_graph_digest_t incomplete = {0}, complete = {0}; + ASSERT_EQ(gd_sql(cbm_store_get_db(store), + "ALTER TABLE index_coverage_meta ADD COLUMN " + "unresolved_calls_complete INTEGER NOT NULL DEFAULT 0;"), + SQLITE_OK); + ASSERT_TRUE(gd_p(store, &incomplete)); + ASSERT_EQ(gd_sql(cbm_store_get_db(store), + "UPDATE index_coverage_meta SET unresolved_calls_complete=1;"), + SQLITE_OK); + ASSERT_TRUE(gd_p(store, &complete)); + cbm_store_close(store); + ASSERT_TRUE(gd_different(&incomplete, &complete)); + PASS(); +} + TEST(store_graph_digest_schema_and_runtime_types_fail_closed) { static const char *const bad[] = { "DROP TABLE file_hashes", "DROP TABLE project_summaries", "DROP TABLE lsp_surface", @@ -595,11 +680,14 @@ TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates) { } SUITE(store_graph_digest) { + RUN_TEST(store_graph_digest_binds_unresolved_candidate_index); + RUN_TEST(store_graph_digest_candidate_index_errors_are_not_legacy_absence); RUN_TEST(store_graph_digest_independent_canonical_golden); RUN_TEST(store_graph_digest_logical_order_and_excluded_storage_are_stable); RUN_TEST(store_graph_digest_every_known_value_and_cross_project_edge_is_bound); RUN_TEST(store_graph_digest_raw_types_project_bytes_and_schema_sql_are_distinct); RUN_TEST(store_graph_digest_legacy_generation_and_live_schema_compatibility); + RUN_TEST(store_graph_digest_binds_unresolved_capture_completeness); RUN_TEST(store_graph_digest_schema_and_runtime_types_fail_closed); RUN_TEST(store_graph_digest_wal_pins_old_content_then_observes_next_scope); RUN_TEST(store_graph_digest_exact_budgets_zero_output_and_terminal_scope);