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);