From 53e2bc24b7fa6ff050bdf3cf1fa0a1e8dbbd132e Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Sun, 5 Jul 2026 02:05:54 -0400 Subject: [PATCH] fix(pipeline): suppress weak external lsp fallbacks When LSP resolves a call to an external or unindexed target, keep strong registry evidence such as same-module/import-map matches but drop weak non-import-reachable short-name fallbacks. This prevents false CALLS edges like scope.get -> local *.get while preserving Python re-export/import behavior such as fastapi.Header. Add a focused pipeline canary for external LSP fallback suppression and keep the existing Python re-export and super().__init__ canaries green. FastAPI matrix remains correctness-safe through the scoped_lsp_gap full-reindex guard, so this is not a default-performance closure. Signed-off-by: Andrew Hundt --- src/pipeline/pass_calls.c | 16 ++++++++++ src/pipeline/pipeline.h | 4 +++ src/pipeline/registry.c | 14 +++++++-- tests/test_pipeline.c | 61 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 10ce85e88..f753fe102 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -298,6 +298,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, /* LSP-resolved calls take precedence over registry-textual matching. */ const CBMResolvedCall *lsp = cbm_lsp_resolution_index_find(lsp_idx, lsp_calls, call, ctx->lsp_confidence_floor); + bool lsp_target_unindexed = false; if (lsp) { const cbm_gbuf_node_t *target_node = calls_lsp_target_node(ctx, lsp->callee_qn); if (target_node && source_node->id != target_node->id) { @@ -314,6 +315,16 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, } return SKIP_ONE; } + if (cbm_service_pattern_route_method(call->callee_name) != NULL) { + const char *handler_ref = NULL; + const char *route_path = cbm_pipeline_call_route_path_and_handler(call, &handler_ref); + if (route_path) { + handle_route_registration(ctx, call, source_node, route_path, handler_ref, + module_qn, imp_keys, imp_vals, imp_count); + return SKIP_ONE; + } + } + lsp_target_unindexed = true; } if (cbm_service_pattern_route_method(call->callee_name) != NULL) { @@ -334,6 +345,11 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, cbm_pipeline_try_field_type_hint(ctx->registry, ctx->gbuf, &res, call->callee_name, source_node->id); + if (lsp_target_unindexed && cbm_registry_strategy_is_weak_short_name(res.strategy) && + !cbm_registry_is_import_reachable(res.qualified_name, imp_vals, imp_count)) { + return 0; + } + /* Perl call-graph noise guard (#476). Perl has no LSP resolver, so the * generic registry chain is the only resolver; for builtins (push/shift/ * keys/...) and method calls ($obj->m with an unresolved receiver), a *weak* diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 68db77020..35bd1b5a7 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -289,6 +289,10 @@ bool cbm_registry_exists(const cbm_registry_t *r, const char *qn); * the name. Perl-scoped: callers gate on the file language. */ bool cbm_perl_is_builtin(const char *name); +/* True for registry strategies that are only weak short-name guesses. Strong + * same-module and import-map matches return false. */ +bool cbm_registry_strategy_is_weak_short_name(const char *strategy); + /* Decide whether a resolved Perl call edge is generic-resolver noise to drop * (#476): true only for Perl, only for a builtin/method call, and only when the * match used a weak short-name strategy — high-confidence same_module/import_map diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 4484fb320..42ef02014 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -393,13 +393,23 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c if (!strategy || !strategy[0]) { return false; } - if (strcmp(strategy, "same_module") == 0 || strcmp(strategy, "import_map") == 0 || - strcmp(strategy, "import_map_suffix") == 0) { + if (!cbm_registry_strategy_is_weak_short_name(strategy)) { return false; /* high-confidence import/same-module match — keep the genuine edge */ } return true; /* weak short-name match (suffix_match / unique_name / …) → drop */ } +bool cbm_registry_strategy_is_weak_short_name(const char *strategy) { + if (!strategy || !strategy[0]) { + return false; + } + if (strcmp(strategy, "same_module") == 0 || strcmp(strategy, "import_map") == 0 || + strcmp(strategy, "import_map_suffix") == 0) { + return false; + } + return true; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index c20384334..a1a305645 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -7956,6 +7956,66 @@ TEST(pipeline_python_super_init_external_lsp_suppresses_suffix_fallback) { PASS(); } +TEST(pipeline_external_lsp_target_suppresses_suffix_fallback) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + cbm_registry_t *reg = cbm_registry_new(); + ASSERT_NOT_NULL(gb); + ASSERT_NOT_NULL(reg); + + int64_t source_id = + cbm_gbuf_upsert_node(gb, "Function", "caller", "proj.app.caller", "app.py", 1, 10, + "{}"); + int64_t suffix_target_id = cbm_gbuf_upsert_node( + gb, "Method", "get", "proj.fastapi.routing.APIRouter.get", "routing.py", 1, 10, "{}"); + int64_t second_suffix_target_id = cbm_gbuf_upsert_node( + gb, "Method", "get", "proj.other.Mapping.get", "other.py", 1, 10, "{}"); + ASSERT_GT(source_id, 0); + ASSERT_GT(suffix_target_id, 0); + ASSERT_GT(second_suffix_target_id, 0); + cbm_registry_add(reg, "get", "proj.fastapi.routing.APIRouter.get", "Method"); + cbm_registry_add(reg, "get", "proj.other.Mapping.get", "Method"); + + CBMFileResult result; + memset(&result, 0, sizeof(result)); + cbm_arena_init(&result.arena); + CBMCall call = {.callee_name = "scope.get", + .enclosing_func_qn = "proj.app.caller", + .start_line = 2}; + cbm_calls_push(&result.calls, &result.arena, call); + CBMResolvedCall resolved = {.caller_qn = "proj.app.caller", + .callee_qn = "external.collections.Mapping.get", + .strategy = "lsp_external_method", + .confidence = 0.95f}; + cbm_resolvedcall_push(&result.resolved_calls, &result.arena, resolved); + CBMFileResult *result_cache[1] = {&result}; + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .registry = reg, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .result_cache = result_cache}; + cbm_file_info_t files[1] = {{.path = "/tmp/proj/app.py", + .rel_path = "app.py", + .language = CBM_LANG_PYTHON}}; + + ASSERT_EQ(cbm_pipeline_pass_calls(&ctx, files, 1), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, source_id, "CALLS", &edges, &edge_count), + 0); + ASSERT_EQ(edge_count, 0); + + cbm_arena_destroy(&result.arena); + cbm_registry_free(reg); + cbm_gbuf_free(gb); + PASS(); +} + TEST(registry_fuzzy_confidence_single) { cbm_registry_t *reg = cbm_registry_new(); cbm_registry_add(reg, "Handler", "proj.svc.Handler", "Function"); @@ -15198,6 +15258,7 @@ SUITE(pipeline) { RUN_TEST(registry_confidence_unique_name); RUN_TEST(registry_confidence_suffix_match); RUN_TEST(pipeline_python_super_init_external_lsp_suppresses_suffix_fallback); + RUN_TEST(pipeline_external_lsp_target_suppresses_suffix_fallback); RUN_TEST(registry_fuzzy_confidence_single); RUN_TEST(registry_fuzzy_confidence_distance); RUN_TEST(registry_negative_import_rejects);