From 8c394658b90dfbe400a107888767143ca94f3826 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 15 Jul 2026 23:43:52 +0200 Subject: [PATCH] fix(pipeline): restore parallel CALLS resolution for LSP-unresolvable targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parallel resolver dropped a CALLS edge whenever the LSP produced a resolution whose callee_qn was not a node in the graph buffer: the registry fallback lived in an else that ran only when NO LSP resolution existed, so an LSP-with-unresolvable-target left res empty and the edge was discarded outright. The sequential pass falls THROUGH to the registry resolver in the same situation. This diverged the two pipelines on the exact intersection the reporter identified (#1085): a JSX component imported through a tsconfig paths alias. The TS LSP resolves the element ref to an alias-path QN that never matches a def node, so lsp_target is NULL and the parallel path dropped it — while sequential resolved it via the import_map / unique_name registry path. On a Next.js repo this silently removed ~21% of the call graph (every alias-imported JSX composition edge), and the parallel path is the default above 50 files, so the default index was the broken one. The fix runs the registry fallback whenever the LSP did not yield a gbuf-resolvable target, matching the sequential fall-through and restoring seq/parallel parity. Reproduce-first: calls_jsx_component_via_tsconfig_alias_parallel_issue1085 indexes the alias+JSX shape through the parallel path (et_index_parallel pads past the 50-file threshold) and asserts the CALLS edges land on the component. RED before (0 edges), GREEN after. Closes #1085 Signed-off-by: Martin Vogel --- src/pipeline/pass_parallel.c | 19 +++++++++--- tests/test_edge_types_probe.c | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 7f025cec..68b66545 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2274,10 +2274,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB if (lsp) { /* Canonicalise to the gbuf node's QN so res.qualified_name matches * the gbuf even when the cross-file fallback had to prefix the - * project name. If neither lookup hits, leave res.qualified_name - * empty — the LSP was confident but its target isn't in the gbuf - * (external/unindexed), so drop the edge rather than fall back to - * the registry resolver, matching prior single-lookup semantics. */ + * project name. */ lsp_target = cbm_pipeline_lsp_target_node(rc->main_gbuf, rc->project_name, lsp->callee_qn, allow_tail); if (lsp_target) { @@ -2287,7 +2284,19 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB res.candidate_count = 1; ws->lsp_overrides++; } - } else { + } + /* #1085: fall back to the registry resolver whenever the LSP did not + * yield a gbuf-resolvable target — whether no LSP resolution existed, + * OR the LSP was confident but its callee_qn isn't a node in the gbuf + * (the JSX-via-tsconfig-alias case: the TS LSP resolves the element + * ref to an alias-path QN that never matches a def node, so lsp_target + * is NULL). The old `else` ran the registry ONLY when lsp was null, so + * an LSP-with-unresolvable-target dropped the edge outright — silently + * losing every alias-imported JSX component edge on the parallel path + * (~21% of a Next.js call graph) while the sequential pass, which falls + * THROUGH to the registry here, kept them. This restores seq/parallel + * parity via the import_map / unique_name resolution. */ + if (!res.qualified_name || !res.qualified_name[0]) { res = cbm_registry_resolve(rc->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count); } diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index 11946613..eae04fd9 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -222,6 +222,63 @@ static cbm_store_t *et_index_parallel(EtProj *lp, const EtFile *meaningful, int return et_index_files(lp, files, n); } +/* #1085: count CALLS edges whose target node has `name`, indexing via the + * PARALLEL path (et_index_parallel pads to >50 files). The parallel resolver + * used to drop the edge whenever the LSP resolved a callee but its target QN + * wasn't a gbuf node (JSX component imported through a tsconfig `paths` alias: + * the TS LSP resolves the element ref to an alias-path QN that never matches a + * def node) — sequential kept the edge via the registry import_map fallback, + * so the two pipelines disagreed and ~21% of a Next.js call graph vanished on + * the default (parallel) path. Needs >50 files to reproduce. */ +static int et_calls_to_name_parallel(const EtFile *meaningful, int n_mean, const char *name) { + EtProj lp; + cbm_store_t *store = et_index_parallel(&lp, meaningful, n_mean); + int hits = 0; + if (store) { + cbm_edge_t *edges = NULL; + int n = 0; + if (cbm_store_find_edges_by_type(store, lp.project, "CALLS", &edges, &n) == CBM_STORE_OK) { + for (int i = 0; i < n; i++) { + cbm_node_t tgt; + if (cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) != CBM_STORE_OK) + continue; + if (tgt.name && strcmp(tgt.name, name) == 0) + hits++; + cbm_node_free_fields(&tgt); + } + cbm_store_free_edges(edges, n); + } + } + et_cleanup(&lp, store); + return hits; +} + +TEST(calls_jsx_component_via_tsconfig_alias_parallel_issue1085) { + static const EtFile f[] = { + {"tsconfig.json", + "{ \"compilerOptions\": { \"baseUrl\": \".\", " + "\"paths\": { \"@/*\": [\"./src/*\"] } } }\n"}, + {"src/components/ui/kpi-card.tsx", + "export function KpiCard({ label }: { label: string }) {\n" + " return
{label}
;\n}\n"}, + {"src/app/dashboard-a.tsx", + "import { KpiCard } from \"@/components/ui/kpi-card\";\n" + "export function DashboardA() {\n return ;\n}\n"}, + {"src/app/dashboard-b.tsx", + "import { KpiCard } from \"@/components/ui/kpi-card\";\n" + "export function DashboardB() {\n return ;\n}\n"}}; + /* RED before the fix: 0 (parallel drops alias-JSX). GREEN: both renders + * resolve, exactly as the sequential path already does. */ + int hits = et_calls_to_name_parallel(f, 4, "KpiCard"); + if (hits < 2) { + fprintf(stderr, " [1085] FAIL CALLS->KpiCard on parallel path = %d (expected >= 2); " + "alias-imported JSX component edges dropped\n", + hits); + } + ASSERT_TRUE(hits >= 2); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * HANDLES — route→handler across web frameworks * @@ -1514,6 +1571,7 @@ TEST(override_go_interface) { SUITE(edge_types_probe) { /* HANDLES — route→handler across web frameworks */ + RUN_TEST(calls_jsx_component_via_tsconfig_alias_parallel_issue1085); RUN_TEST(handles_flask_python); RUN_TEST(handles_fastapi_python); RUN_TEST(handles_drf_action_python);