diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index a00192a18..a8e154e99 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -294,6 +294,10 @@ typedef struct { // (self.compiler.apply_converters()). An object the // class owns, not a parameter: read by the weak-member // guard's unique-name exemption. Default false. + /* Ephemeral coverage evidence, set only while resolving this file. Capture + * it before releasing a spilled result; never retain registry pointers. */ + bool coverage_calls_emitted; + const char *coverage_candidate_qn; } CBMCall; typedef struct { diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d6c7bd468..c169f1c9f 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -64,6 +64,7 @@ enum { #include "watcher/watcher.h" #include "foundation/mem.h" #include "foundation/mem_core.h" +#include "foundation/hash_table.h" #include "foundation/diagnostics.h" #include "foundation/platform.h" #include "foundation/compat.h" @@ -6085,11 +6086,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; @@ -6119,6 +6122,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); @@ -6176,6 +6184,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); @@ -6213,7 +6227,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, " @@ -6221,7 +6235,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."); } } @@ -6462,6 +6478,9 @@ static const char *coverage_status(const cbm_coverage_row_t *rows, int count, if (pass == 0 && strcmp(kind, "parse_partial") == 0) { return "partial"; } + if (pass == 0 && strcmp(kind, "unresolved_calls") == 0) { + return "unresolved_calls"; + } if (pass == 1 && strncmp(kind, "not_indexed", 11) == 0) { return "excluded"; } @@ -6486,6 +6505,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. */ @@ -8998,6 +9020,147 @@ static int clamp_mcp_depth(int depth, const char *tool) { return depth; } +typedef struct { + const cbm_node_t *roots; + int root_count; + const cbm_traverse_result_t *outbound; + const cbm_traverse_result_t *inbound; + bool include_tests; + bool do_outbound; + bool do_inbound; +} trace_coverage_scope_t; + +typedef struct { + CBMHashTable *callers; + CBMHashTable *targets; + bool unresolved_out; + bool unresolved_in; +} trace_coverage_match_t; + +static bool trace_coverage_add_node(CBMHashTable *table, const cbm_node_t *node) { + if (!table || !node->qualified_name) { + return true; + } + cbm_ht_set(table, node->qualified_name, (void *)node); + return cbm_ht_has(table, node->qualified_name); +} + +static bool trace_coverage_add_visited(CBMHashTable *table, const cbm_traverse_result_t *tr, + bool include_tests) { + if (!table) { + return true; + } + for (int i = 0; i < tr->visited_count; i++) { + const cbm_node_t *node = &tr->visited[i].node; + if ((include_tests || !is_test_file(node->file_path)) && + !trace_coverage_add_node(table, node)) { + return false; + } + } + return true; +} + +static bool trace_coverage_build_sets(trace_coverage_match_t *match, + const trace_coverage_scope_t *scope) { + for (int i = 0; i < scope->root_count; i++) { + if (!trace_coverage_add_node(match->callers, &scope->roots[i]) || + !trace_coverage_add_node(match->targets, &scope->roots[i])) { + return false; + } + } + return trace_coverage_add_visited(match->callers, scope->outbound, scope->include_tests) && + trace_coverage_add_visited(match->targets, scope->inbound, scope->include_tests); +} + +static void trace_coverage_match_site(trace_coverage_match_t *match, const char *rel_path, + yyjson_val *site) { + const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller")); + const char *candidate = yyjson_get_str(yyjson_obj_get(site, "candidate")); + const cbm_node_t *caller_node = + match->callers && caller ? cbm_ht_get(match->callers, caller) : NULL; + int line = yyjson_get_int(yyjson_obj_get(site, "line")); + /* The producer joins the exact byte span to the extractor's innermost + * caller. Check its file and source range too; never match a containing + * factory merely because its range overlaps. */ + if (caller_node && caller_node->file_path && strcmp(caller_node->file_path, rel_path) == 0 && + (line <= 0 || (line >= caller_node->start_line && line <= caller_node->end_line))) { + match->unresolved_out = true; + } + /* A short-name collision is not evidence that the resolver considered + * this traced target. */ + if (match->targets && candidate && cbm_ht_has(match->targets, candidate)) { + match->unresolved_in = true; + } +} + +static void trace_coverage_match_row(trace_coverage_match_t *match, const cbm_coverage_row_t *row, + const trace_coverage_scope_t *scope) { + yyjson_doc *detail = yyjson_read(row->detail, strlen(row->detail), 0); + yyjson_val *sites = detail ? yyjson_doc_get_root(detail) : NULL; + if (!yyjson_is_arr(sites)) { + match->unresolved_out = scope->do_outbound; + match->unresolved_in = scope->do_inbound; + } else { + size_t idx; + size_t max; + yyjson_val *site; + yyjson_arr_foreach(sites, idx, max, site) { + trace_coverage_match_site(match, row->rel_path, site); + } + } + if (detail) { + yyjson_doc_free(detail); + } +} + +static bool trace_coverage_scan_rows(cbm_store_t *store, const char *project, + trace_coverage_match_t *match, + const trace_coverage_scope_t *scope) { + cbm_coverage_row_t *rows = NULL; + int row_count = 0; + if (cbm_store_coverage_get_unresolved_calls(store, project, &rows, &row_count) != + CBM_STORE_OK) { + return false; + } + for (int i = 0; i < row_count; i++) { + if ((!scope->do_outbound || match->unresolved_out) && + (!scope->do_inbound || match->unresolved_in)) { + break; + } + trace_coverage_match_row(match, &rows[i], scope); + } + cbm_store_free_coverage(rows, row_count); + return true; +} + +static bool trace_coverage_meta_available(cbm_store_t *store, const char *project) { + cbm_coverage_meta_t meta = {0}; + int rc = cbm_store_coverage_meta_get(store, project, &meta); + bool available = rc == CBM_STORE_OK && + meta.coverage_version >= CBM_UNRESOLVED_CALL_COVERAGE_VERSION && + meta.recording_status && strcmp(meta.recording_status, "unavailable") != 0; + cbm_store_coverage_meta_clear(&meta); + return available; +} + +static void trace_call_coverage(cbm_store_t *store, const char *project, + const trace_coverage_scope_t *scope, bool includes_calls, + bool *unresolved_out, bool *unresolved_in) { + if (!includes_calls) { + return; + } + trace_coverage_match_t match = {.callers = scope->do_outbound ? cbm_ht_create(0) : NULL, + .targets = scope->do_inbound ? cbm_ht_create(0) : NULL}; + bool ok = trace_coverage_meta_available(store, project) && + (!scope->do_outbound || match.callers) && (!scope->do_inbound || match.targets) && + trace_coverage_build_sets(&match, scope) && + trace_coverage_scan_rows(store, project, &match, scope); + *unresolved_out = ok ? match.unresolved_out : scope->do_outbound; + *unresolved_in = ok ? match.unresolved_in : scope->do_inbound; + cbm_ht_free(match.callers); + cbm_ht_free(match.targets); +} + static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { char *func_name = cbm_mcp_get_string_arg(args, "function_name"); char *project = get_project_arg(args); @@ -9324,6 +9487,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; @@ -9412,7 +9596,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); @@ -9423,7 +9607,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); @@ -9489,8 +9673,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, @@ -9499,8 +9682,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, @@ -9558,13 +9740,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) { @@ -9589,12 +9769,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) { @@ -10076,11 +10255,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 @@ -10090,17 +10270,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); } @@ -10111,7 +10294,7 @@ static void add_skipped_summary(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_val *files = yyjson_mut_arr(doc); int shown = 0; for (int i = 0; i < count && shown < INDEX_SKIPPED_FILE_CAP; i++) { - if (is_parse_coverage(&errs[i])) { + if (is_indexed_coverage(&errs[i])) { continue; } yyjson_mut_val *fe = yyjson_mut_obj(doc); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 84958954c..e8257f880 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -205,7 +205,7 @@ static void free_import_map(const char **keys, const char **vals, int count) { } /* Handle a route registration call: create Route node + HANDLES edge. */ -static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *call, +static void handle_route_registration(cbm_pipeline_ctx_t *ctx, CBMCall *call, const cbm_gbuf_node_t *source_node, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count) { const char *method = cbm_service_pattern_route_method(call->callee_name); @@ -225,7 +225,9 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn, esc_fa); - cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props); + if (cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props) > 0) { + call->coverage_calls_emitted = true; + } if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') { cbm_resolution_t hres = cbm_registry_resolve(ctx->registry, call->second_arg_name, module_qn, imp_keys, imp_vals, imp_count); @@ -284,7 +286,7 @@ static int64_t create_svc_route_node(cbm_pipeline_ctx_t *ctx, const char *url, c * this, so data_flow mode had no argument expressions to surface for small * (< 50 file) repos that take the sequential path (#514). Mirrors the parallel * path's append_args_json shape so both pipelines agree. */ -static void calls_append_args(char *props, size_t cap, const CBMCall *call) { +static void calls_append_args(char *props, size_t cap, CBMCall *call) { if (!call || call->arg_count <= 0) { return; } @@ -331,7 +333,7 @@ static void calls_append_args(char *props, size_t cap, const CBMCall *call) { } static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const char *type, - char *props, size_t cap, const CBMCall *call) { + char *props, size_t cap, CBMCall *call) { if (call && call->start_line > 0 && strcmp(type, "CALLS") == 0) { size_t len = strlen(props); if (len >= SKIP_ONE && props[len - SKIP_ONE] == '}' && len + CBM_SZ_32 < cap) { @@ -342,10 +344,13 @@ static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const ch if (call && strcmp(type, "CALLS") == 0) { calls_append_args(props, cap, call); } - cbm_gbuf_insert_edge(gbuf, src, tgt, type, props); + if (cbm_gbuf_insert_edge(gbuf, src, tgt, type, props) > 0 && call && + strcmp(type, "CALLS") == 0) { + call->coverage_calls_emitted = true; + } } -static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, +static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target, const cbm_resolution_t *res, cbm_svc_kind_t svc, bool suppress_plain_calls) { @@ -414,7 +419,7 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, * match, #592/#606), the route/HTTP/ASYNC/CONFIG service classifications below * still run — only the plain CALLS fall-through is skipped, so a fabricated * project edge is dropped while every service edge stays main-identical. */ -static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, +static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, CBMCall *call, const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target, const cbm_resolution_t *res, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, @@ -504,7 +509,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, res.qualified_name = target_node->qualified_name; res.confidence = lsp->confidence; res.strategy = lsp->strategy; - res.candidate_count = 1; + res.candidate_count = CBM_ALLOC_ONE; + call->coverage_candidate_qn = target_node->qualified_name; emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, false); return SKIP_ONE; @@ -682,6 +688,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, if (cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { return 0; } + call->coverage_candidate_qn = target_node->qualified_name; emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, drop_plain_call); return SKIP_ONE; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f..e081c3c3a 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1916,7 +1916,7 @@ static int format_call_arg(char *buf, size_t bufsize, const CBMCallArg *a, const return snprintf(buf, bufsize, "{\"i\":%d,\"e\":\"%s\"}", a->index, esc_e); } -static size_t append_args_json(char *buf, size_t bufsize, size_t pos, const CBMCall *call) { +static size_t append_args_json(char *buf, size_t bufsize, size_t pos, CBMCall *call) { if (call->arg_count == 0 || pos >= bufsize - PP_ARGS_MARGIN) { return pos; } @@ -1972,7 +1972,7 @@ static bool is_route_path_shaped(const char *val) { return val && val[0] == '/' && !cbm_service_pattern_is_comment_text(val); } -static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler) { +static const char *find_route_path_in_args(CBMCall *call, const char **out_handler) { *out_handler = NULL; /* 1. First string arg starting with / */ if (is_route_path_shaped(call->first_string_arg)) { @@ -2012,7 +2012,7 @@ static const char *find_route_path_in_args(const CBMCall *call, const char **out /* Build props JSON, append args, close brace, emit edge. */ static void finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id, - const char *edge_type, char *props, int n, const CBMCall *call) { + const char *edge_type, char *props, int n, CBMCall *call) { if (n > 0 && (size_t)n < CBM_SZ_2K - PP_ESC_SPACE) { size_t pos = append_args_json(props, CBM_SZ_2K, (size_t)n, call); if (call->start_line > 0 && strcmp(edge_type, "CALLS") == 0 && @@ -2027,7 +2027,10 @@ static void finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id, props[pos + SKIP_ONE] = '\0'; } } - cbm_gbuf_insert_edge(gbuf, src_id, tgt_id, edge_type, props); + if (cbm_gbuf_insert_edge(gbuf, src_id, tgt_id, edge_type, props) > 0 && + strcmp(edge_type, "CALLS") == 0) { + call->coverage_calls_emitted = true; + } } /* Build Route node QN and properties for HTTP/async service edges. */ @@ -2057,7 +2060,7 @@ static int64_t build_service_route(cbm_gbuf_t *gbuf, const char *arg, const char /* Emit HTTP_CALLS or ASYNC_CALLS edge via Route node. */ static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const CBMCall *call, const cbm_resolution_t *res, + CBMCall *call, const cbm_resolution_t *res, cbm_svc_kind_t svc, const char *arg) { const char *edge_type = (svc == CBM_SVC_HTTP) ? "HTTP_CALLS" : "ASYNC_CALLS"; const char *method = @@ -2084,7 +2087,7 @@ static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t /* Emit CONFIGURES edge. */ static void emit_config_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const cbm_gbuf_node_t *target, const CBMCall *call, + const cbm_gbuf_node_t *target, CBMCall *call, const cbm_resolution_t *res, const char *arg) { /* emit_service_edge may be reached with target==NULL on the HTTP/ASYNC * external-client bypass (#523); a CONFIGURES edge needs a real target, so @@ -2104,7 +2107,7 @@ static void emit_config_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, /* Emit normal CALLS edge. */ static void emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const cbm_gbuf_node_t *target, const CBMCall *call, + const cbm_gbuf_node_t *target, CBMCall *call, const cbm_resolution_t *res) { /* A CALLS edge needs a real target; the HTTP/ASYNC external-client bypass * (#523) can reach emit_service_edge with target==NULL, so guard the deref. */ @@ -2123,11 +2126,11 @@ static void emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sour /* Classify a resolved call by library identity and emit the appropriate edge. */ /* Create Route node + CALLS + HANDLES edges for a route registration call. */ -static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const CBMCall *call, const char *route_path, - const char *handler_ref, const char *module_qn, - const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, - const char **ik, const char **iv, int ic) { +static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, + const char *route_path, const char *handler_ref, + const char *module_qn, const cbm_registry_t *registry, + const cbm_gbuf_t *main_gbuf, const char **ik, const char **iv, + int ic) { const char *method = cbm_service_pattern_route_method(call->callee_name); char rqn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; @@ -2144,7 +2147,9 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn, esc_rp); - cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props); + if (cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props) > 0) { + call->coverage_calls_emitted = true; + } if (handler_ref && handler_ref[0] != '\0') { cbm_resolution_t hres = cbm_registry_resolve(registry, handler_ref, module_qn, ik, iv, ic); if (hres.qualified_name && hres.qualified_name[0] != '\0') { @@ -2212,8 +2217,7 @@ static bool normalize_url_arg(const char *url, char *norm, int norm_sz) { } /* Detect API paths in call arguments and create HTTP_CALLS edges. */ -static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const CBMCall *call) { +static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call) { for (int ai = 0; ai < call->arg_count; ai++) { const CBMCallArg *ca = &call->args[ai]; /* A slash-prefixed raw expression is not a URL string. In JS/TS this @@ -2318,7 +2322,7 @@ bool extract_grpc_service_method(const char *callee, char *service, size_t srv_s } /* Emit GRPC_CALLS edge via gRPC Route node. */ -static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, +static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, const cbm_resolution_t *res) { char service[CBM_SZ_256]; char method[CBM_SZ_256]; @@ -2355,7 +2359,7 @@ static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons } /* Emit GRAPHQL_CALLS edge. Extract operation from first string arg if available. */ -static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, +static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, const cbm_resolution_t *res) { const char *op = call->first_string_arg; if (!op || !op[0]) { @@ -2392,7 +2396,7 @@ static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, c } /* Emit TRPC_CALLS edge. Extract procedure path from callee chain. */ -static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, +static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, CBMCall *call, const cbm_resolution_t *res) { /* tRPC calls: trpc.user.getById.query() → extract "user.getById" */ const char *callee = call->callee_name; @@ -2435,7 +2439,7 @@ static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons * a verb-suffix HTTP client (api.patch('/x')), broker, or route registration * keeps its edge; only the fabricated project CALLS edge is dropped. */ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const cbm_gbuf_node_t *target, const CBMCall *call, + const cbm_gbuf_node_t *target, CBMCall *call, const cbm_resolution_t *res, const char *module_qn, const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, const char **imp_keys, const char **imp_vals, int imp_count, @@ -2692,7 +2696,7 @@ static bool lsp_idx_insert_leaf(CBMHashTable *index, CBMResolvedCall *candidate, return inserted; } -static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, const CBMCall *call, +static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, CBMCall *call, bool exact_site, bool *key_built, bool *ambiguous) { if (key_built) { *key_built = false; @@ -3049,6 +3053,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } continue; } + call->coverage_candidate_qn = target_node->qualified_name; _rc_t0 = extract_now_ns(); emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, @@ -3449,6 +3454,28 @@ static bool pp_has_pending_lsp_site(const CBMFileResult *result) { return false; } +static CBMFileResult *resolve_load_result(resolve_ctx_t *rc, resolve_worker_state_t *ws, + int file_idx) { + if (ws->loaded) { + cbm_free_result(ws->loaded); + ws->loaded = NULL; + } + CBMFileResult *result = rc->result_cache[file_idx]; + if (!result && rc->pctx && rc->pctx->spill && cbm_result_spill_has(rc->pctx->spill, file_idx)) { + result = cbm_result_spill_load(rc->pctx->spill, file_idx); + ws->loaded = result; + if (!result) { + cbm_pipeline_mark_unresolved_capture_failed(rc->pctx->pipeline); + } + } + return result; +} + +static void resolve_capture_coverage(const resolve_ctx_t *rc, const char *rel, + const CBMFileResult *result) { + cbm_pipeline_record_unresolved_calls(rc->pctx ? rc->pctx->pipeline : NULL, rel, result); +} + static void resolve_worker(int worker_id, void *ctx_ptr) { resolve_ctx_t *rc = ctx_ptr; resolve_worker_state_t *ws = &rc->workers[worker_id]; @@ -3482,16 +3509,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { uint64_t _loop_t0 = extract_now_ns(); - if (ws->loaded) { - cbm_free_result(ws->loaded); - ws->loaded = NULL; - } - CBMFileResult *result = rc->result_cache[file_idx]; - if (!result && rc->pctx && rc->pctx->spill && - cbm_result_spill_has(rc->pctx->spill, file_idx)) { - result = cbm_result_spill_load(rc->pctx->spill, file_idx); - ws->loaded = result; - } + CBMFileResult *result = resolve_load_result(rc, ws, file_idx); if (!result) { atomic_fetch_add_explicit(&rc->time_ns_total_loop, extract_now_ns() - _loop_t0, memory_order_relaxed); @@ -3547,6 +3565,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { if (result->calls.count == 0 && result->usages.count == 0 && result->throws.count == 0 && result->rw.count == 0 && result->defs.count == 0 && result->impl_traits.count == 0 && !cross_lsp_eligible) { + resolve_capture_coverage(rc, rel, result); continue; } @@ -3695,6 +3714,10 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { atomic_fetch_add_explicit(&rc->time_ns_semantic, extract_now_ns() - _ph_t0, memory_order_relaxed); + /* The cross-file additions and actual emitted-site evidence live in + * this result, including when it was loaded from the spill store. */ + resolve_capture_coverage(rc, rel, result); + cbm_registry_reach_cache_end(); cbm_registry_import_map_cache_end(); cbm_registry_resolve_cache_end(); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index fa62441a1..df58bb5ac 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,9 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6 }; #include "foundation/mem.h" #include "foundation/mem_core.h" #include "result_spill.h" +#include "cbm.h" #include "foundation/secure_random.h" +#include "yyjson/yyjson.h" #include #include @@ -253,6 +256,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 *); @@ -346,6 +356,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; @@ -420,6 +432,269 @@ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *row p->surface_row_count = count; } +static void *unresolved_json_alloc(void *ctx, size_t size) { + (void)ctx; + return cbm_alloc(CBM_MEM_CLASS_DUMP, size); +} + +static void *unresolved_json_realloc(void *ctx, void *ptr, size_t old_size, size_t size) { + (void)ctx; + (void)old_size; + return cbm_realloc(CBM_MEM_CLASS_DUMP, ptr, size); +} + +static void unresolved_json_free(void *ctx, void *ptr) { + (void)ctx; + cbm_free(CBM_MEM_CLASS_DUMP, ptr); +} + +enum { UNRESOLVED_KEY_OVERHEAD = 128, UNRESOLVED_INITIAL_ROWS = 64, UNRESOLVED_ROW_GROWTH = 2 }; + +static char *unresolved_site_key(const char *caller, const char *leaf, uint32_t start, uint32_t end, + CBMSourceOrigin origin) { + size_t cn = strlen(caller); + size_t ln = strlen(leaf); + size_t cap = cn + ln + UNRESOLVED_KEY_OVERHEAD; + char *key = cbm_alloc(CBM_MEM_CLASS_DUMP, cap); + if (key) { + (void)snprintf(key, cap, "%zu:%s|%zu:%s|%u:%u:%u", cn, caller, ln, leaf, start, end, + (unsigned)origin); + } + return key; +} + +typedef struct { + const CBMCall *call; +} unresolved_carrier_t; + +typedef struct { + yyjson_mut_doc *doc; + yyjson_mut_val *sites; + CBMHashTable *carriers; + CBMHashTable *seen; +} unresolved_capture_t; + +static void unresolved_carrier_free(const char *key, void *value, void *ctx) { + (void)ctx; + cbm_free(CBM_MEM_CLASS_DUMP, (void *)key); + cbm_free(CBM_MEM_CLASS_DUMP, value); +} + +static void unresolved_key_free(const char *key, void *value, void *ctx) { + (void)value; + (void)ctx; + cbm_free(CBM_MEM_CLASS_DUMP, (void *)key); +} + +static void unresolved_capture_free(unresolved_capture_t *capture) { + if (capture->carriers) { + cbm_ht_foreach(capture->carriers, unresolved_carrier_free, NULL); + cbm_ht_free(capture->carriers); + } + if (capture->seen) { + cbm_ht_foreach(capture->seen, unresolved_key_free, NULL); + cbm_ht_free(capture->seen); + } + if (capture->doc) { + yyjson_mut_doc_free(capture->doc); + } +} + +static bool unresolved_call_is_missing(const CBMResolvedCall *rc) { + return rc->kind == CBM_RESOLVED_INVOCATION && rc->strategy && + strcmp(rc->strategy, "lsp_unresolved") == 0 && rc->caller_qn && rc->callee_qn; +} + +static bool unresolved_has_missing_calls(const CBMFileResult *result) { + for (int i = 0; i < result->resolved_calls.count; i++) { + if (unresolved_call_is_missing(&result->resolved_calls.items[i])) { + return true; + } + } + return false; +} + +static bool unresolved_add_carrier(CBMHashTable *carriers, const CBMCall *call) { + if (!call->callee_name || call->site_end_byte <= call->site_start_byte) { + return true; + } + char *key = + unresolved_site_key("", cbm_lsp_bare_segment(call->callee_name), call->site_start_byte, + call->site_end_byte, call->source_origin); + if (!key) { + return false; + } + if (cbm_ht_has(carriers, key)) { + /* Ambiguous extractor occurrences must never erase a diagnostic. */ + unresolved_carrier_t *carrier = cbm_ht_get(carriers, key); + carrier->call = NULL; + cbm_free(CBM_MEM_CLASS_DUMP, key); + return true; + } + unresolved_carrier_t *carrier = cbm_alloc(CBM_MEM_CLASS_DUMP, sizeof(*carrier)); + if (!carrier) { + cbm_free(CBM_MEM_CLASS_DUMP, key); + return false; + } + carrier->call = call; + cbm_ht_set(carriers, key, carrier); + if (!cbm_ht_has(carriers, key)) { + cbm_free(CBM_MEM_CLASS_DUMP, key); + cbm_free(CBM_MEM_CLASS_DUMP, carrier); + return false; + } + return true; +} + +static bool unresolved_build_carriers(CBMHashTable *carriers, const CBMFileResult *result) { + /* Caller names in the TS LSP walk can denote an outer factory. The + * extractor's exact occurrence is the authoritative enclosing function. + * Index by span AND leaf: two receiver calls on one line remain distinct. */ + for (int i = 0; i < result->calls.count; i++) { + if (!unresolved_add_carrier(carriers, &result->calls.items[i])) { + return false; + } + } + return true; +} + +static bool unresolved_append_site(unresolved_capture_t *capture, const CBMResolvedCall *rc, + const CBMCall *call, const char *caller, const char *leaf) { + yyjson_mut_doc *doc = capture->doc; + yyjson_mut_val *site = yyjson_mut_obj(doc); + bool ok = + site && yyjson_mut_obj_add_strcpy(doc, site, "caller", caller) && + yyjson_mut_obj_add_strcpy(doc, site, "leaf", leaf) && + yyjson_mut_obj_add_uint(doc, site, "start_byte", rc->site_start_byte) && + yyjson_mut_obj_add_uint(doc, site, "end_byte", rc->site_end_byte) && + yyjson_mut_obj_add_uint(doc, site, "source_origin", rc->source_origin) && + yyjson_mut_obj_add_strcpy(doc, site, "reason", rc->reason ? rc->reason : "unresolved"); + if (ok && call && call->start_line > 0) { + ok = yyjson_mut_obj_add_int(doc, site, "line", call->start_line); + } + if (ok && call && call->coverage_candidate_qn) { + ok = yyjson_mut_obj_add_strcpy(doc, site, "candidate", call->coverage_candidate_qn); + } + return ok && yyjson_mut_arr_add_val(capture->sites, site); +} + +static bool unresolved_collect_site(unresolved_capture_t *capture, const CBMResolvedCall *rc) { + const char *leaf = cbm_lsp_bare_segment(rc->callee_qn); + char *lookup = + unresolved_site_key("", leaf, rc->site_start_byte, rc->site_end_byte, rc->source_origin); + if (!lookup) { + return false; + } + const unresolved_carrier_t *carrier = cbm_ht_get(capture->carriers, lookup); + const CBMCall *call = carrier ? carrier->call : NULL; + cbm_free(CBM_MEM_CLASS_DUMP, lookup); + /* This exact occurrence emitted a real CALLS edge, possibly via the + * registry after LSP failure. Never suppress a different occurrence. */ + if (call && call->coverage_calls_emitted) { + return true; + } + const char *caller = call && call->enclosing_func_qn ? call->enclosing_func_qn : rc->caller_qn; + char *key = unresolved_site_key(caller, leaf, rc->site_start_byte, rc->site_end_byte, + rc->source_origin); + if (!key) { + return false; + } + if (cbm_ht_has(capture->seen, key)) { + cbm_free(CBM_MEM_CLASS_DUMP, key); + return true; + } + cbm_ht_set(capture->seen, key, key); + if (!cbm_ht_has(capture->seen, key)) { + cbm_free(CBM_MEM_CLASS_DUMP, key); + return false; + } + return unresolved_append_site(capture, rc, call, caller, leaf); +} + +static bool unresolved_collect_sites(unresolved_capture_t *capture, const CBMFileResult *result) { + for (int i = 0; i < result->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &result->resolved_calls.items[i]; + if (unresolved_call_is_missing(rc) && !unresolved_collect_site(capture, rc)) { + return false; + } + } + return true; +} + +/* Caller holds unresolved_mutex. Ownership transfers only on success. */ +static bool unresolved_append_row(cbm_pipeline_t *p, const char *path, const char *detail) { + if (!detail || !path || p->unresolved_capture_failed) { + return false; + } + if (p->unresolved_count == p->unresolved_capacity) { + int capacity = p->unresolved_capacity ? p->unresolved_capacity * UNRESOLVED_ROW_GROWTH + : UNRESOLVED_INITIAL_ROWS; + cbm_coverage_row_t *rows = + cbm_realloc(CBM_MEM_CLASS_DUMP, p->unresolved_rows, (size_t)capacity * sizeof(*rows)); + if (!rows) { + return false; + } + p->unresolved_rows = rows; + p->unresolved_capacity = capacity; + } + p->unresolved_rows[p->unresolved_count++] = + (cbm_coverage_row_t){.rel_path = path, .kind = "unresolved_calls", .detail = detail}; + return true; +} + +void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path, + const CBMFileResult *result) { + if (!p || !rel_path || !result || p->unresolved_capture_failed) { + return; + } + if (!unresolved_has_missing_calls(result)) { + return; + } + yyjson_alc allocator = {.malloc = unresolved_json_alloc, + .realloc = unresolved_json_realloc, + .free = unresolved_json_free}; + unresolved_capture_t capture = {.doc = yyjson_mut_doc_new(&allocator), + .carriers = cbm_ht_create(0), + .seen = cbm_ht_create(0)}; + capture.sites = capture.doc ? yyjson_mut_arr(capture.doc) : NULL; + bool ok = capture.doc && capture.carriers && capture.seen && capture.sites; + if (capture.doc) { + yyjson_mut_doc_set_root(capture.doc, capture.sites); + } + ok = ok && unresolved_build_carriers(capture.carriers, result) && + unresolved_collect_sites(&capture, result); + if (ok && yyjson_mut_arr_size(capture.sites) > 0) { + char *detail = yyjson_mut_write_opts(capture.doc, 0, &allocator, NULL, NULL); + char *path = cbm_mem_strdup(CBM_MEM_CLASS_DUMP, rel_path); + cbm_mutex_lock(&p->unresolved_mutex); + ok = unresolved_append_row(p, path, detail); + cbm_mutex_unlock(&p->unresolved_mutex); + if (!ok) { + cbm_free(CBM_MEM_CLASS_DUMP, detail); + cbm_free(CBM_MEM_CLASS_DUMP, path); + } + } + 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; +} + void cbm_pipeline_free(cbm_pipeline_t *p) { if (!p) { return; @@ -450,6 +725,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_DUMP, (char *)p->unresolved_rows[i].rel_path); + cbm_free(CBM_MEM_CLASS_DUMP, (char *)p->unresolved_rows[i].detail); + } + cbm_free(CBM_MEM_CLASS_DUMP, p->unresolved_rows); + cbm_mutex_destroy(&p->unresolved_mutex); cbm_git_context_free(&p->git_ctx); /* gbuf, store, registry freed during/after run */ /* Defensively free userconfig in case run() was never called or panicked */ @@ -1458,6 +1739,9 @@ static int run_sequential_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, if (seq_cache) { for (int i = 0; i < file_count; i++) { if (seq_cache[i]) { + if (rc == 0) { + cbm_pipeline_record_unresolved_calls(p, files[i].rel_path, seq_cache[i]); + } cbm_free_result(seq_cache[i]); } } @@ -2486,14 +2770,18 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas return CBM_PIPELINE_ABORT_PRESERVE_DB; } - int cov_total = p->file_errors_count + p->excluded_count + p->ignored_count; + int cov_total = + p->file_errors_count + p->excluded_count + p->ignored_count + p->unresolved_count; cbm_coverage_row_t *cov = NULL; int cov_count = 0; - bool coverage_rows_available = cov_total == 0; + bool coverage_rows_available = cov_total == 0 && !p->unresolved_capture_failed; if (cov_total > 0) { cov = malloc((size_t)cov_total * sizeof(*cov)); if (cov) { - coverage_rows_available = true; + coverage_rows_available = !p->unresolved_capture_failed; + for (int i = 0; i < p->unresolved_count; i++) { + cov[cov_count++] = p->unresolved_rows[i]; + } for (int i = 0; i < p->file_errors_count; i++) { cov[cov_count++] = (cbm_coverage_row_t){.rel_path = p->file_errors[i].path, .kind = p->file_errors[i].phase, diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 154518658..9d34ff241 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -30,6 +30,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24 }; #include "foundation/compat_thread.h" #include "foundation/platform.h" #include "foundation/sha256.h" +#include "result_spill.h" #include #include @@ -1401,6 +1402,14 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed if (rc == 0) { rc = cbm_pipeline_pass_semantic(ctx, changed_files, ci); } + if (rc == 0 && cache) { + for (int i = 0; i < ci; i++) { + if (cache[i]) { + cbm_pipeline_record_unresolved_calls(ctx->pipeline, changed_files[i].rel_path, + cache[i]); + } + } + } if (rc == 0) { rc = cbm_pipeline_check_cancel(ctx); } @@ -2237,11 +2246,17 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char int run_ignored_count = 0; int run_ignored_total = 0; cbm_pipeline_get_ignored(p, &run_ignored, &run_ignored_count, &run_ignored_total); - int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count; - bool coverage_rows_available = cov_cap == 0; + cbm_coverage_row_t *run_unresolved = NULL; + int run_unresolved_count = 0; + bool unresolved_complete = false; + cbm_pipeline_get_unresolved_calls(p, &run_unresolved, &run_unresolved_count, + &unresolved_complete); + int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count + + run_unresolved_count; + bool coverage_rows_available = cov_cap == 0 && unresolved_complete; if (cov_cap > 0) { cov = (cbm_coverage_row_t *)malloc((size_t)cov_cap * sizeof(*cov)); - coverage_rows_available = cov != NULL; + coverage_rows_available = cov != NULL && unresolved_complete; } if (cov) { for (int i = 0; i < old_cov_count; i++) { @@ -2257,6 +2272,9 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char cov[cov_n].detail = run_errs[i].reason; cov_n++; } + for (int i = 0; i < run_unresolved_count; i++) { + cov[cov_n++] = run_unresolved[i]; + } for (int i = 0; i < run_excluded_count; i++) { cov[cov_n].rel_path = run_excluded[i]; cov[cov_n].kind = "not_indexed_dir"; @@ -2810,11 +2828,17 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil cbm_pipeline_get_ignored(p, &run_ignored, &run_ignored_count, &run_ignored_total); cbm_coverage_row_t *cov = NULL; int cov_n = 0; - int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count; + cbm_coverage_row_t *run_unresolved = NULL; + int run_unresolved_count = 0; + bool unresolved_complete = false; + cbm_pipeline_get_unresolved_calls(p, &run_unresolved, &run_unresolved_count, + &unresolved_complete); + int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count + + run_unresolved_count; if (cov_cap > 0) { cov = (cbm_coverage_row_t *)malloc((size_t)cov_cap * sizeof(*cov)); } - bool coverage_rows_available = cov_cap == 0 || cov != NULL; + bool coverage_rows_available = (cov_cap == 0 || cov != NULL) && unresolved_complete; if (cov) { CBMHashTable *changed_set = cbm_ht_create(ci > 0 ? (size_t)ci * PAIR_LEN : CBM_SZ_64); for (int i = 0; i < ci; i++) { @@ -2834,6 +2858,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil cov[cov_n].detail = run_errs[i].reason; cov_n++; } + for (int i = 0; i < run_unresolved_count; i++) { + cov[cov_n++] = run_unresolved[i]; + } for (int i = 0; i < run_excluded_count; i++) { cov[cov_n].rel_path = run_excluded[i]; cov[cov_n].kind = "not_indexed_dir"; diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index e7782adef..182241eca 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -789,7 +789,7 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr /* Compatibility contract persisted in coverage metadata. Increment when a * graph/manifest semantic change makes prior exact-input indexes unsafe. */ -enum { CBM_SEMANTIC_INDEX_VERSION = 3 }; +enum { CBM_SEMANTIC_INDEX_VERSION = CBM_UNRESOLVED_CALL_COVERAGE_VERSION }; typedef struct { cbm_gbuf_t *gbuf; @@ -874,6 +874,11 @@ void cbm_pipeline_discard_stage(const char *stage_path); * Takes ownership; dump_and_persist_hashes writes them into the staging * store and cbm_pipeline_free releases them. Passing NULL/0 clears. */ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *rows, int count); +void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path, + const CBMFileResult *result); +void cbm_pipeline_mark_unresolved_capture_failed(cbm_pipeline_t *p); +void cbm_pipeline_get_unresolved_calls(cbm_pipeline_t *p, cbm_coverage_row_t **rows, int *count, + bool *complete); /* Pipeline accessors for incremental use */ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p); diff --git a/src/store/store.c b/src/store/store.c index fa074cb5d..5d06f33a6 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -329,6 +329,8 @@ static int init_schema(cbm_store_t *s) { " detail TEXT DEFAULT ''," " PRIMARY KEY (project, rel_path, kind)" ");" + "CREATE INDEX IF NOT EXISTS idx_index_coverage_project_kind " + "ON index_coverage(project, kind);" /* One row per completed coverage persistence attempt. Kept separate * from projects so existing graph/artifact schema stays compatible and * a missing row unambiguously means coverage metadata is unavailable. */ @@ -4208,6 +4210,14 @@ int cbm_store_coverage_get_scope(cbm_store_t *s, const char *project, const char return coverage_query_rows(s, project, scope, sql, out, count); } +int cbm_store_coverage_get_unresolved_calls(cbm_store_t *s, const char *project, + cbm_coverage_row_t **out, int *count) { + static const char sql[] = "SELECT rel_path, kind, detail FROM index_coverage " + "WHERE project = ?1 AND kind = ?2 " + "ORDER BY rel_path;"; + return coverage_query_rows(s, project, "unresolved_calls", sql, out, count); +} + void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) { if (!meta) { return; diff --git a/src/store/store.h b/src/store/store.h index b6ce844f7..2163eed09 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -639,7 +639,9 @@ int cbm_store_delete_file_hashes(cbm_store_t *s, const char *project); /* One best-effort coverage row: a file the indexer could not fully cover. * kind "parse_partial" = indexed but the parse tree had ERROR/MISSING regions * (detail = 1-based line ranges "12-40,88-90"); skip kinds "read"/"extract"/ - * "oversized" = not indexed at all (detail = reason). Stored in the separate + * "oversized" = not indexed at all (detail = reason). "unresolved_calls" is + * indexed source whose invocation targets were not resolved (detail = JSON + * array of caller, leaf, source byte span, and reason). Stored in the separate * index_coverage table — coverage is metadata ABOUT the graph, never mixed * into the graph itself. */ typedef struct { @@ -648,6 +650,10 @@ typedef struct { const char *detail; } cbm_coverage_row_t; +/* Older generations 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. @@ -690,6 +696,9 @@ int cbm_store_coverage_get_path(cbm_store_t *s, const char *project, const char * ancestor that covers the scope. Prefix matching is segment-boundary safe. */ int cbm_store_coverage_get_scope(cbm_store_t *s, const char *project, const char *scope, cbm_coverage_row_t **out, int *count); +/* Fetch only unresolved invocation diagnostics for trace exactness checks. */ +int cbm_store_coverage_get_unresolved_calls(cbm_store_t *s, const char *project, + cbm_coverage_row_t **out, int *count); /* Fetch/free the metadata paired with the current coverage row set. */ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ffa9ea3a3..979660b34 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -5914,9 +5914,9 @@ TEST(tool_check_index_coverage_freshness_uses_indexer_mtime_source_issue1714) { ASSERT_EQ(cbm_path_info_utf8(source_path, &info), 0); /* The hash exactly as the indexer writes it: same source, ns precision. */ - ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", info.mtime_ns, - info.size), - CBM_STORE_OK); + ASSERT_EQ( + cbm_store_upsert_file_hash(store, "test-project", "main.go", "", info.mtime_ns, info.size), + CBM_STORE_OK); /* format=json, like every other coverage test here: the DEFAULT response is * the compact table, in which a field name never appears. Keeping the @@ -5933,11 +5933,11 @@ TEST(tool_check_index_coverage_freshness_uses_indexer_mtime_source_issue1714) { /* A hash stored at seconds precision — what a stat-based reader previously * compared against — must NOT match an unchanged file: the comparison must * stay nanosecond-exact, or part of mtime resolution is silently dropped. */ - int64_t seconds_mtime_ns = (info.mtime_ns / (int64_t)CBM_NSEC_PER_SEC) * - (int64_t)CBM_NSEC_PER_SEC; + int64_t seconds_mtime_ns = + (info.mtime_ns / (int64_t)CBM_NSEC_PER_SEC) * (int64_t)CBM_NSEC_PER_SEC; if (seconds_mtime_ns != info.mtime_ns) { - ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", - seconds_mtime_ns, info.size), + ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", seconds_mtime_ns, + info.size), CBM_STORE_OK); response = cbm_mcp_handle_tool(srv, "check_index_coverage", "{\"project\":\"test-project\",\"paths\":[\"main.go\"]," @@ -6581,6 +6581,13 @@ TEST(tool_trace_budget_never_slices_identifiers) { const char *project = "trace-byte-budget"; cbm_mcp_server_set_project(srv, project); ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/trace-byte-budget"), CBM_STORE_OK); + cbm_coverage_meta_t coverage_meta = {.generation = "fixture", + .index_mode = "full", + .recorded_at = "2026-09-24T00:00:00Z", + .recording_status = "complete", + .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .hash_records_complete = true}; + ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, NULL, 0, &coverage_meta), CBM_STORE_OK); cbm_node_t hub = {.project = project, .label = "Function", @@ -6926,6 +6933,13 @@ TEST(tool_trace_reports_engine_saturation_as_lower_bound) { const char *project = "trace-engine-cap"; cbm_mcp_server_set_project(srv, project); ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/trace-engine-cap"), CBM_STORE_OK); + cbm_coverage_meta_t coverage_meta = {.generation = "fixture", + .index_mode = "full", + .recorded_at = "2026-09-24T00:00:00Z", + .recording_status = "complete", + .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION, + .hash_records_complete = true}; + ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, NULL, 0, &coverage_meta), CBM_STORE_OK); cbm_node_t hub = {.project = project, .label = "Function", .name = "hub", @@ -7355,6 +7369,164 @@ TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped) { PASS(); } +TEST(tool_trace_path_marks_unresolved_call_totals_unknown) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "unresolved-trace"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/unresolved-trace"); + cbm_node_t caller = {.project = proj, + .label = "Function", + .name = "run", + .qualified_name = "unresolved-trace.service.run", + .file_path = "service.js", + .start_line = 1, + .end_line = 4}; + cbm_node_t callee = {.project = proj, + .label = "Function", + .name = "buscar", + .qualified_name = "unresolved-trace.client.buscar", + .file_path = "client.js", + .start_line = 1, + .end_line = 2}; + ASSERT_GT(cbm_store_upsert_node(st, &caller), 0); + ASSERT_GT(cbm_store_upsert_node(st, &callee), 0); + ASSERT_EQ(cbm_store_upsert_file_hash(st, proj, "service.js", "fixture", 0, 0), CBM_STORE_OK); + char *out = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":" + "\"unresolved-trace\",\"function_name\":\"run\",\"direction\":\"outbound\"," + "\"format\":\"json\"}}}"); + char *txt = extract_text_content(out); + ASSERT_NOT_NULL(strstr(txt, "\"callees_total_relation\":\"unknown\"")); + free(txt); + free(out); + cbm_coverage_row_t row = { + .rel_path = "service.js", + .kind = "unresolved_calls", + .detail = "[{\"caller\":\"unresolved-trace.service.run\",\"leaf\":\"buscar\"," + "\"start_byte\":42,\"end_byte\":59,\"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, + .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_index_unresolved_nested_functions_are_indexed) { + char tmp[256] = "/tmp/cbm-mcp-unresolved-XXXXXX"; + char cache[256] = "/tmp/cbm-mcp-unresolved-cache-XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + char path[512]; + snprintf(path, sizeof(path), "%s/service.js", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fputs("export function factory({ client }) {\n" + " function inner(id) {\n" + " return client.buscar(id);\n" + " }\n return { inner };\n}\n", + f); + fclose(f); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", tmp); + char *out = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(out); + ASSERT(response_contains_json_fragment(out, "\"status\":\"indexed\"")); + ASSERT(response_contains_json_fragment(out, "\"skipped_count\":0")); + free(out); + char *project = cbm_project_name_from_path(tmp); + ASSERT_NOT_NULL(project); + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"inner\"," + "\"direction\":\"outbound\",\"format\":\"json\"}", + project); + out = cbm_mcp_handle_tool(srv, "trace_path", args); + ASSERT_NOT_NULL(out); + ASSERT(response_contains_json_fragment(out, "\"callees_total_relation\":\"unknown\"")); + free(out); + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"factory\"," + "\"direction\":\"outbound\",\"format\":\"json\"}", + project); + out = cbm_mcp_handle_tool(srv, "trace_path", args); + ASSERT_NOT_NULL(out); + ASSERT(response_contains_json_fragment(out, "\"callees_total_relation\":\"eq\"")); + free(out); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(path); + cbm_rmdir(tmp); + cbm_rmdir(cache); + PASS(); +} + /* Edge-data lookup must follow the traversal direction and shortest-path hop. * The induced edge set contains a same-hop cross edge whose source is the row * node and sorts before its real inbound predecessor. An arbitrary incident- @@ -20582,6 +20754,8 @@ SUITE(mcp) { RUN_TEST(tool_trace_call_path_prefers_definition); RUN_TEST(trace_evidence_strategy_class_vocabulary_is_closed); RUN_TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped); + RUN_TEST(tool_trace_path_marks_unresolved_call_totals_unknown); + RUN_TEST(tool_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 690fb9664..ad238ceaf 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -541,6 +541,200 @@ TEST(pipeline_adr_survives_full_reindex) { PASS(); } +static int pipeline_unresolved_review_case(int padding, bool spill) { + char tmp[256] = "/tmp/cbm_unresolved_review_XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char db[512], path[512]; + const char *names[] = {"cliente.js", "servicio.js", "ayudante.js", + "directo.js", "missing.js", "route.js"}; + const char *sources[] = { + "export function crearCliente() { function buscar(id) { return id; } return { buscar }; " + "}\n", + "export function crearServicio({ cliente }) {\n" + " function procesar(id) {\n" + " cliente.buscar(id); return cliente.buscar(id + 1);\n" + " }\n return { procesar };\n}\n", + "export function ayudante(id) { return id; }\n", + "import { ayudante } from './ayudante.js';\n" + "export function usarDirecto(id) { ayudante(id); return ayudante(id + 1); }\n", + "import { absent } from './ayudante.js';\n" + "export function missing(id) { return absent(id); }\n", + "export function setup({ app }) { app.get('/x', handler); }\n" + "function handler() { return 1; }\n"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + snprintf(path, sizeof(path), "%s/%s", tmp, names[i]); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fputs(sources[i], f); + fclose(f); + } + for (int i = 0; i < padding; i++) { + snprintf(path, sizeof(path), "%s/pad%d.js", tmp, i); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "export function pad%d() { return %d; }\n", i, i); + fclose(f); + } + snprintf(db, sizeof(db), "%s/index.db", tmp); + if (spill) + cbm_setenv("CBM_MEM_SPILL", "1", 1); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + int rc = cbm_pipeline_run(p); + if (spill) + cbm_unsetenv("CBM_MEM_SPILL"); + ASSERT_EQ(rc, 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + cbm_store_t *st = cbm_store_open_path(db); + ASSERT_NOT_NULL(st); + cbm_coverage_row_t *rows = NULL; + int count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "directo.js", &rows, &count), CBM_STORE_OK); + for (int i = 0; i < count; i++) + ASSERT_FALSE(strcmp(rows[i].kind, "unresolved_calls") == 0); + cbm_store_free_coverage(rows, count); + rows = NULL; + count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "route.js", &rows, &count), CBM_STORE_OK); + for (int i = 0; i < count; i++) + ASSERT_FALSE(strcmp(rows[i].kind, "unresolved_calls") == 0); + cbm_store_free_coverage(rows, count); + rows = NULL; + count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "servicio.js", &rows, &count), CBM_STORE_OK); + ASSERT_EQ(count, 1); + yyjson_doc *doc = yyjson_read(rows[0].detail, strlen(rows[0].detail), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *sites = yyjson_doc_get_root(doc); + ASSERT_EQ(yyjson_arr_size(sites), 2); + for (size_t i = 0; i < 2; i++) { + yyjson_val *site = yyjson_arr_get(sites, i); + const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller")); + ASSERT_NOT_NULL(caller); + ASSERT_NOT_NULL(strstr(caller, ".procesar")); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(site, "line")), 3); + ASSERT_NOT_NULL(yyjson_get_str(yyjson_obj_get(site, "candidate"))); + } + yyjson_doc_free(doc); + cbm_store_free_coverage(rows, count); + rows = NULL; + count = 0; + ASSERT_EQ(cbm_store_coverage_get_path(st, project, "missing.js", &rows, &count), CBM_STORE_OK); + ASSERT_EQ(count, 1); + doc = yyjson_read(rows[0].detail, strlen(rows[0].detail), 0); + ASSERT_NOT_NULL(doc); + ASSERT_EQ(yyjson_arr_size(yyjson_doc_get_root(doc)), 1); + yyjson_doc_free(doc); + cbm_store_free_coverage(rows, count); + cbm_store_close(st); + rm_rf(tmp); + PASS(); +} + +TEST(pipeline_unresolved_review_sequential) { + return pipeline_unresolved_review_case(0, false); +} +TEST(pipeline_unresolved_review_parallel) { + return pipeline_unresolved_review_case(55, false); +} +TEST(pipeline_unresolved_review_spill) { + return pipeline_unresolved_review_case(55, true); +} + +TEST(pipeline_records_unresolved_injected_call_sites) { + char tmp[256] = "/tmp/cbm_unresolved_calls_XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + 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"); @@ -15308,6 +15502,10 @@ SUITE(pipeline) { RUN_TEST(pipeline_structure_nodes); RUN_TEST(pipeline_committed_counts_match_persisted); RUN_TEST(pipeline_adr_survives_full_reindex); + RUN_TEST(pipeline_records_unresolved_injected_call_sites); + RUN_TEST(pipeline_unresolved_review_sequential); + RUN_TEST(pipeline_unresolved_review_parallel); + RUN_TEST(pipeline_unresolved_review_spill); RUN_TEST(pipeline_structure_edges); RUN_TEST(pipeline_branch_root_structure); RUN_TEST(pipeline_project_name_derived);