/* * repro_invariant_lsp_rescue.c — QUALITY_ANALYSIS gap #5 / #5a: * the LSP rescue cannot recover a bad tree-sitter caller QN because the * join key is exact caller-QN string equality. * * THE BLOCKER (file:func:line): * cbm_pipeline_find_lsp_resolution (src/pipeline/lsp_resolve.h:48) * joins each LSP-resolved call (CBMResolvedCall) to the tree-sitter call * (CBMCall) with EXACT string equality on the caller QN: * * lsp_resolve.h:65: * if (strcmp(rc->caller_qn, call->enclosing_func_qn) != 0) * continue; * * Consumed by: * - src/pipeline/pass_calls.c:369 (sequential pipeline, * resolve_single_call → emit_classified_edge) * - src/pipeline/pass_parallel.c:1797 (parallel pipeline) * * When tree-sitter's enclosing-func walk FAILS, cbm_enclosing_func_qn * falls back to the MODULE QN, so call->enclosing_func_qn is the module * QN. The C/C++ LSP cross resolver (internal/cbm/lsp/c_lsp.c) builds its * OWN enclosing QN from scope resolution — for an out-of-line method * Foo::bar it produces the real method QN "..Foo.bar" * (c_process_function, c_lsp.c:4138-4143) and emits a CBMResolvedCall * with caller_qn = that real method QN, strategy = "lsp_direct" / * "lsp_implicit_this" / "lsp_type_dispatch", confidence 0.95 * (c_emit_resolved_call, c_lsp.c:3287-3296). 0.95 is well above * CBM_LSP_CONFIDENCE_FLOOR (0.6f, lsp_resolve.h:36). * * So the LSP HAS the correct caller, but the join key on the * tree-sitter side is the MODULE QN. module-QN != real-method-QN, the * strcmp at lsp_resolve.h:65 never matches, find_lsp_resolution returns * NULL, the LSP rescue branch (pass_calls.c:370-385) is skipped, and the * edge falls through to the registry resolver — staying Module-sourced * with a registry strategy. The LSP rescue is silently DISCARDED. * * FIXTURE RATIONALE (C++ out-of-line method — the #554 family): * A free function helper() and a class Processor with an OUT-OF-LINE * method definition Processor::run that calls helper(v). For the * out-of-line method body, tree-sitter's cbm_find_enclosing_func cannot * walk the call-expression's ancestry back to a node whose type is in * func_kinds_cpp = {"function_definition"} in a way that yields the * class-qualified method QN, so cbm_enclosing_func_qn falls back to the * module QN (issue #554 / extract_defs.c + c_lsp.c dominate the * QUALITY_ANALYSIS Module-sourced-CALLS top-file list). C/C++ has a * cross-file LSP wired up (cbm_pxc_has_cross_lsp, pass_lsp_cross.c:281), * so the LSP DOES resolve the real Processor::run caller. This is the * cleanest fixture where tree-sitter attribution lands on Module but the * LSP resolves the real enclosing function — exactly gap #5a. * * EXPECTED vs ACTUAL: * EXPECTED (correct, what the fix must produce): the helper() CALLS edge * is sourced at the real callable node Processor::run (label * "Function"/"Method"), via the LSP rescue, and its properties_json * carries the LSP strategy marker (strategy starts with "lsp_") and the * LSP confidence (0.95). * ACTUAL (today, RED): the join discards the LSP result, so the edge is * Module-sourced and its properties carry a registry strategy * (same_module / import_map / ...), never an "lsp_" strategy. * * This file deliberately complements repro_invariant_calls.c: that file * asserts the broad "zero Module-sourced CALLS" invariant; THIS file * pins the *mechanism* — that the LSP rescue specifically is the missing * recovery, by also asserting the rescued edge preserves the LSP * strategy/confidence in its properties_json (gap #5a, second assertion). * * NOTE: line comments only inside this header (no block comments inside a * block comment, per coding rules). */ #include "test_framework.h" #include "repro_harness.h" #include #include /* ── Fixture ────────────────────────────────────────────────────────────── */ /* * Out-of-line method Processor::run calls the free function helper(). * - helper : free function, definition-style body. * - Processor::run: OUT-OF-LINE method definition. tree-sitter's * enclosing-func walk falls back to the module QN here * (#554), but the C++ LSP resolves caller = Processor::run. * The call we care about is `helper(v)` inside Processor::run. */ static const char kCppOutOfLine[] = "static int helper(int x) { return x * 2; }\n" "\n" "class Processor {\n" "public:\n" " int run(int v);\n" "};\n" "\n" "int Processor::run(int v) {\n" " return helper(v);\n" "}\n"; /* ── Locate the helper() CALLS edge ─────────────────────────────────────── */ /* * find_call_edge_to_helper * * Scan all CALLS edges and return (by out-params) the one whose TARGET node * qualified_name ends in ".helper" — that is the `helper(v)` call site inside * Processor::run. Copies the source node and the edge's properties_json into * caller-owned buffers so the caller can assert after freeing the edge array. * * Returns 1 if found, 0 otherwise. */ static int find_call_edge_to_helper(cbm_store_t *store, const char *project, cbm_node_t *out_src, char *out_props, size_t props_cap) { cbm_edge_t *edges = NULL; int nedges = 0; if (cbm_store_find_edges_by_type(store, project, "CALLS", &edges, &nedges) != CBM_STORE_OK) { return 0; } int found = 0; for (int i = 0; i < nedges; i++) { cbm_node_t tgt; if (cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) != CBM_STORE_OK) { continue; } const char *tqn = tgt.qualified_name ? tgt.qualified_name : ""; size_t tlen = strlen(tqn); const char *suffix = ".helper"; size_t slen = strlen(suffix); if (tlen < slen || strcmp(tqn + tlen - slen, suffix) != 0) { continue; } /* This is the helper() call edge. Capture its source node + props. */ if (cbm_store_find_node_by_id(store, edges[i].source_id, out_src) == CBM_STORE_OK) { const char *props = edges[i].properties_json ? edges[i].properties_json : "{}"; snprintf(out_props, props_cap, "%s", props); found = 1; } break; } cbm_store_free_edges(edges, nedges); return found; } /* ── #5: rescued edge must be callable-sourced via the LSP caller ───────── */ /* * repro_invariant_lsp_rescue_source * * Expected: RED on current code. * * The helper() call inside the out-of-line method Processor::run must be * sourced at the real callable node (label "Function" or "Method") — the * LSP resolves caller = Processor::run, which should rescue the bad * tree-sitter Module attribution. * * Today the join in cbm_pipeline_find_lsp_resolution (lsp_resolve.h:65) * requires rc->caller_qn == call->enclosing_func_qn; tree-sitter supplies * the MODULE QN, the LSP supplies the real method QN, they never strcmp * equal, the LSP rescue is discarded, and the edge stays Module-sourced. * So src.label == "Module" → this assertion FAILS (RED), proving the bug. */ TEST(repro_invariant_lsp_rescue_source) { RProj lp; cbm_store_t *store = rh_index(&lp, "main.cpp", kCppOutOfLine); ASSERT_TRUE(store != NULL); cbm_node_t src; char props[1024]; int found = find_call_edge_to_helper(store, lp.project, &src, props, sizeof(props)); /* Sanity: the helper() CALLS edge must exist at all, else no signal. */ ASSERT_TRUE(found == 1); const char *lbl = src.label ? src.label : "(null)"; /* * INVARIANT (RED today): the edge is sourced at the real callable * (Function/Method), NOT at the Module. The only path that can produce * this for an out-of-line method whose tree-sitter enclosing is Module * is the LSP rescue — which the exact-QN join discards today. */ ASSERT_TRUE(strcmp(lbl, "Function") == 0 || strcmp(lbl, "Method") == 0); rh_cleanup(&lp, store); return 0; } /* ── #5a: rescued edge must preserve the LSP strategy/confidence ────────── */ /* * repro_invariant_lsp_rescue_props * * Expected: RED on current code. * * Per QUALITY_ANALYSIS gap #5a, when the LSP rescues a call the emitted * edge must record the LSP provenance. pass_calls.c:374-381 copies * res.strategy = lsp->strategy and res.confidence = lsp->confidence into * the edge, and emit_classified_edge writes them into properties_json as * {"callee":"...","confidence":0.95,"strategy":"lsp_...","candidates":1} * (pass_calls.c:336-340). The C++ LSP strategies are all "lsp_"-prefixed * (lsp_direct / lsp_implicit_this / lsp_type_dispatch / lsp_virtual_dispatch * / lsp_base_dispatch / lsp_smart_ptr_dispatch, c_lsp.c:3390-3658) at * confidence 0.95. * * Today the rescue never fires (join discarded), so the surviving edge is * registry-resolved and its strategy is a registry strategy (same_module / * import_map / ...), never "lsp_". The substring "\"strategy\":\"lsp_" is * therefore ABSENT from properties_json → this assertion FAILS (RED). * * If a future change emits the rescued edge but with different property * keys, update the marker here; the source-label invariant in the test * above is the primary, key-independent signal. */ TEST(repro_invariant_lsp_rescue_props) { RProj lp; cbm_store_t *store = rh_index(&lp, "main.cpp", kCppOutOfLine); ASSERT_TRUE(store != NULL); cbm_node_t src; char props[1024]; int found = find_call_edge_to_helper(store, lp.project, &src, props, sizeof(props)); ASSERT_TRUE(found == 1); /* * INVARIANT (RED today): the rescued edge's properties_json carries the * LSP strategy marker. We look for a "strategy" value beginning with * "lsp_" — the prefix shared by every C/C++ LSP strategy string. */ int has_lsp_strategy = (strstr(props, "\"strategy\":\"lsp_") != NULL); ASSERT_TRUE(has_lsp_strategy); rh_cleanup(&lp, store); return 0; } /* ── Suite ──────────────────────────────────────────────────────────────── */ SUITE(repro_invariant_lsp_rescue) { RUN_TEST(repro_invariant_lsp_rescue_source); RUN_TEST(repro_invariant_lsp_rescue_props); }