/* * repro_issue546.c — Reproduce-first case for OPEN bug #546. * * Issue: #546 — "trace_path / reverse-dependency returns an INCOMPLETE caller * set when a symbol is duplicated by an ambient .d.ts declaration * (callers silently split by import style)" * * Root cause (graph layer — node identity / dedup across the ambient declaration): * When a TypeScript symbol is BOTH defined in a real .ts source file AND * re-declared (body-less, signature only) in an ambient .d.ts shim file, * the indexer creates TWO distinct Function nodes for the same logical symbol * (one rooted at the .ts implementation, one rooted at the .d.ts stub). * * CALLS edges from consumers are then partitioned across the two nodes based * on which import form each consumer used: * - consumer importing via relative path ("./scroll") → CALLS edge targets * the IMPLEMENTATION node (packages/widget/src/scroll.ts) * - consumer importing via path alias ("@widget") → CALLS edge targets * the .d.ts STUB node (app/types/widget-shim.d.ts) * * trace_path resolves the symbol name to EXACTLY ONE of the two nodes (the * first one returned by cbm_store_find_nodes_by_name) and BFS-traverses only * that node's inbound CALLS edges. The callers whose edges point to the OTHER * node are silently omitted from the result. There is no warning that the * symbol resolved to multiple nodes and the caller set is therefore partial. * * Expected (correct) behaviour: * trace_path(function_name="alignToEdge", direction="inbound") must return * ALL callers, regardless of which import style they used: * {"callers": [{name: "internalConsumer", ...}, {name: "externalConsumer", ...}]} * Both "internalConsumer" AND "externalConsumer" must appear in the response. * * Actual (buggy) behaviour: * Only ONE of the two callers appears in the "callers" array. The other is * silently dropped because its CALLS edge points to the sibling node (the * other representation of the same logical symbol) that trace_path did not * select as its BFS root. * * Why RED on current code: * The final assertion checks that BOTH caller names appear in the trace_path * JSON response. On buggy code, trace_path picks one of the two Function * nodes for "alignToEdge" as its BFS root; the inbound CALLS edges of the * OTHER node are never visited; one caller name is absent from the JSON; * the strstr check for the missing name returns NULL → * ASSERT_NOT_NULL(strstr(resp, "...")) FAILS → RED. * * Precondition strategy: * Before driving trace_path, the test checks that BOTH callers produced * at least one CALLS edge each (total CALLS edges ≥ 2). If this precondition * fires RED it flags an extraction failure (TS CALLS extraction not working), * not the #546 traversal bug. Separation keeps the root cause unambiguous. * * TS CALLS extraction reliability note: * TypeScript CALLS extraction is confirmed reliable for simple intra-package * call expressions by existing integration tests (test_extraction.c and the * regression suite). The known risk here is the path-alias import form * ("@widget") — the extractor may or may not resolve the alias and produce * a CALLS edge for externalConsumer. If the precondition (total CALLS ≥ 2) * fires first, the alias resolution is the cause, not the #546 split. * A secondary precondition after the main assertion ensures that even if only * one CALLS edge is produced (alias unresolved), the test is still RED for * the right reason: incomplete caller set. * * Fix location (not implemented here): * Either in cbm_store_find_nodes_by_name / cbm_store_bfs (union traversal * across all nodes sharing name+signature), or in the pipeline dedup step * where body-less .d.ts stub nodes should be merged/aliased into their * implementation counterpart rather than stored as separate graph nodes. */ #include #include "test_framework.h" #include "repro_harness.h" #include #include #include /* ── Fixture ──────────────────────────────────────────────────────────────── * * Minimal TypeScript monorepo layout that triggers the dual-node split: * * packages/widget/src/scroll.ts * — real implementation of alignToEdge(); exports the function * * packages/widget/src/internalConsumer.ts * — imports alignToEdge via RELATIVE path ("./scroll") * — calls alignToEdge(document.createElement('div')) * → CALLS edge targets the IMPLEMENTATION node * * app/types/widget-shim.d.ts * — ambient .d.ts declaration; body-less signature of alignToEdge * — this causes the indexer to create a SECOND (stub) Function node * * app/src/externalConsumer.ts * — imports alignToEdge via PATH ALIAS ("@widget") * — calls alignToEdge(document.querySelector('div')) * → CALLS edge targets the .d.ts STUB node (the alias points there) * * On buggy code: two Function nodes for "alignToEdge"; trace_path picks one; * only one caller is returned. * * Note: The tsconfig.json is included so the indexer can, in principle, * resolve the "@widget" path alias to packages/widget/src. Alias resolution * is best-effort in the current extractor; even without it, if the .d.ts stub * causes a second node, the externalConsumer CALLS edge will point to that * stub node, and the test assertion will correctly turn RED. */ static const RFile k_files[] = { /* tsconfig: maps @widget alias to packages/widget/src */ { "tsconfig.json", "{\n" " \"compilerOptions\": {\n" " \"baseUrl\": \".\",\n" " \"paths\": {\n" " \"@widget\": [\"packages/widget/src\"]\n" " }\n" " }\n" "}\n" }, /* Real implementation — produces the IMPLEMENTATION Function node */ { "packages/widget/src/scroll.ts", "export function alignToEdge(el: HTMLElement): () => void {\n" " return function() { el.scrollIntoView({ block: 'nearest' }); };\n" "}\n" }, /* Internal consumer: relative import → CALLS edge → IMPLEMENTATION node */ { "packages/widget/src/internalConsumer.ts", "import { alignToEdge } from './scroll';\n" "const node = document.createElement('div');\n" "const cleanup = alignToEdge(node);\n" "export { cleanup };\n" }, /* Ambient .d.ts shim — triggers the SECOND (stub) Function node creation */ { "app/types/widget-shim.d.ts", "export function alignToEdge(el: HTMLElement): () => void;\n" }, /* External consumer: alias import → CALLS edge → .d.ts STUB node */ { "app/src/externalConsumer.ts", "import { alignToEdge } from '@widget';\n" "const div = document.querySelector('div') as HTMLElement;\n" "const teardown = alignToEdge(div);\n" "export { teardown };\n" } }; /* ───────────────────────────────────────────────────────────────────────── * repro_issue546_dts_split_caller_set * * Precondition A (must be GREEN to prove extraction is working): * At least 1 CALLS edge exists in the graph (the internalConsumer relative * import is the most reliable and must produce a CALLS edge). * * The failing assertion (RED on buggy code): * trace_path for "alignToEdge" with direction="inbound" returns a "callers" * array that contains BOTH "internalConsumer" AND "externalConsumer". * * The test is RED when EITHER name is absent — the partial set is the bug. * ───────────────────────────────────────────────────────────────────────── */ TEST(repro_issue546_dts_split_caller_set) { RProj lp; cbm_store_t *store = rh_index_files(&lp, k_files, (int)(sizeof(k_files) / sizeof(k_files[0]))); ASSERT_NOT_NULL(store); /* ── Precondition A: at least one CALLS edge must exist ───────────── * If this fires RED, TS CALLS extraction is broken for this fixture — * that is a pre-existing extraction bug, not #546. The test cannot * distinguish the traversal split without any edges to split across. * * Minimum: 1 (internalConsumer's relative-path import always resolves). * Ideally 2 (externalConsumer's alias import also resolves), but even * 1 is enough to trigger the .d.ts node creation that causes the split. */ int calls_count = rh_count_edges(store, lp.project, "CALLS"); ASSERT_GT(calls_count, 0); /* precondition — not the #546 assertion */ /* ── Drive trace_path: inbound callers of "alignToEdge" ───────────── * * Args: * function_name — bare symbol name; the indexer mints node names * matching the short function name for both the impl * and the .d.ts stub node. * project — lp.project (derived from tmpdir) * direction — "inbound": who calls alignToEdge? * depth — 2: one hop is enough (caller → alignToEdge) * * On CORRECT code (fixed): * trace_path unions all Function nodes named "alignToEdge" and returns * callers from all of them: * {"callers":[{"name":"internalConsumer",...},{"name":"externalConsumer",...}]} * * On BUGGY code (current): * trace_path resolves "alignToEdge" to ONE node (first match from * cbm_store_find_nodes_by_name). Only callers whose CALLS edges * point to THAT node appear. The other caller is silently absent. */ char args[512]; snprintf(args, sizeof(args), "{\"function_name\":\"alignToEdge\"," "\"project\":\"%s\"," "\"direction\":\"inbound\"," "\"depth\":2}", lp.project); char *resp = cbm_mcp_handle_tool(lp.srv, "trace_path", args); ASSERT_NOT_NULL(resp); /* Symbol must be found — if "function not found" fires, the name lookup * itself has a problem unrelated to #546. */ ASSERT_NULL(strstr(resp, "function not found")); /* "callers" key must appear (always emitted when direction is inbound). * The response is the MCP envelope (inner json embedded as an escaped * string), so the key appears as \"callers\" — match the escaped form. */ ASSERT_NOT_NULL(strstr(resp, "\\\"callers\\\"")); /* The callers array must not be empty — at least the internalConsumer * (whose relative-path import is reliably resolved) must appear. * * WHY this might already be RED for #546: * If trace_path selected the .d.ts stub node as BFS root, only * externalConsumer is there; internalConsumer's edge is on the impl * node, so this check fires RED immediately (callers:[]) or wrong name. */ ASSERT_NULL(strstr(resp, "\\\"callers\\\":[]")); /* empty = traversal totally wrong */ /* ── PRIMARY ASSERTION: BOTH callers must appear in the response ───── * * "internalConsumer" — imports via relative path, CALLS edge → impl node * "externalConsumer" — imports via alias, CALLS edge → .d.ts stub node * * On CORRECT (fixed) code: trace_path unions both nodes; both names present. * * WHY RED on buggy code: * trace_path selects ONE of the two "alignToEdge" nodes as its BFS root. * Only that node's inbound CALLS edges are traversed. The caller whose * CALLS edge points to the OTHER node is absent from the JSON response. * strstr() for the missing caller name returns NULL, and ASSERT_NOT_NULL * fires → RED. * * Concretely: * — if impl node selected: "externalConsumer" absent → RED * — if .d.ts node selected: "internalConsumer" absent → RED * Either way, exactly one of the two assertions below is RED, * proving the caller set is split and incomplete. */ ASSERT_NOT_NULL(strstr(resp, "internalConsumer")); /* relative-import caller */ ASSERT_NOT_NULL(strstr(resp, "externalConsumer")); /* alias-import caller */ free(resp); rh_cleanup(&lp, store); PASS(); } /* ── Suite ─────────────────────────────────────────────────────────────── */ SUITE(repro_issue546) { RUN_TEST(repro_issue546_dts_split_caller_set); }