diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo index 0da71675ba..a9f802d132 100644 --- a/graph-ui/tsconfig.tsbuildinfo +++ b/graph-ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.test.tsx","./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/ControlTab.tsx","./src/components/DisplaySettingsMenu.tsx","./src/components/EdgeLines.tsx","./src/components/ErrorBoundary.tsx","./src/components/FilterPanel.tsx","./src/components/GraphLoader.tsx","./src/components/GraphScene.test.ts","./src/components/GraphScene.tsx","./src/components/GraphTab.deadcode.test.tsx","./src/components/GraphTab.filters.test.tsx","./src/components/GraphTab.selection.test.tsx","./src/components/GraphTab.test.ts","./src/components/GraphTab.tsx","./src/components/MissedCallout.tsx","./src/components/NodeCloud.tsx","./src/components/NodeDetailPanel.test.tsx","./src/components/NodeDetailPanel.tsx","./src/components/NodeLabels.tsx","./src/components/NodeTooltip.tsx","./src/components/ProjectCard.tsx","./src/components/ResizeHandle.tsx","./src/components/Sidebar.tsx","./src/components/StatsTab.test.tsx","./src/components/StatsTab.tsx","./src/components/TabBar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/useGraphData.test.ts","./src/hooks/useGraphData.ts","./src/hooks/useProjects.test.tsx","./src/hooks/useProjects.ts","./src/lib/colors.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index e352217998..ce68d66b57 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -6794,8 +6794,11 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, def.end_line = ts_node_end_point(child).row + TS_LINE_OFFSET; def.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET); def.is_exported = cbm_is_exported(name, ctx->language); - if (ctx->language == CBM_LANG_RUST && - strcmp(ts_node_type(child), "function_signature_item") == 0) { + if ((ctx->language == CBM_LANG_RUST && + strcmp(ts_node_type(child), "function_signature_item") == 0) || + ((ctx->language == CBM_LANG_TYPESCRIPT || ctx->language == CBM_LANG_TSX) && + (strcmp(ts_node_type(child), "method_signature") == 0 || + strcmp(ts_node_type(child), "abstract_method_signature") == 0))) { def.is_abstract = true; } @@ -6949,6 +6952,22 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c continue; } + // TS interface members and abstract class members are signatures, not + // method_definition, so function_node_types skips them. Emit them as + // Methods so a call through an interface-typed field (NestJS token + // injection, #514) has a target. Class-body method_signature nodes are + // overloads of a real method_definition and stay skipped. + if ((ctx->language == CBM_LANG_TYPESCRIPT || ctx->language == CBM_LANG_TSX) && + ((strcmp(ts_node_type(child), "method_signature") == 0 && + strcmp(ts_node_type(class_node), "interface_declaration") == 0) || + strcmp(ts_node_type(child), "abstract_method_signature") == 0)) { + TSNode sname = ts_node_child_by_field_name(child, TS_FIELD("name")); + if (!ts_node_is_null(sname)) { + push_method_def(ctx, child, class_node, class_qn, spec, sname); + } + continue; + } + // ObjectScript UDL wraps each method/classmethod in a class_statement. if (ctx->language == CBM_LANG_OBJECTSCRIPT_UDL && strcmp(ts_node_type(child), "class_statement") == 0) { diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index 967d06762a..bd61a8c8cc 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -4569,6 +4569,16 @@ static void register_file_defs(CBMArena *arena, CBMTypeRegistry *reg, CBMFileRes } cbm_registry_add_type(reg, rt); } else if (strcmp(d->label, "Function") == 0 || strcmp(d->label, "Method") == 0) { + /* Interface members are registered by ast_sweep_shapes from the + * AST, whose signature resolves names against this module first; + * the def text would type `status(): Response` as the global + * lib.dom Response and break chained calls. */ + if (strcmp(d->label, "Method") == 0 && d->parent_class) { + const CBMRegisteredType *owner = cbm_registry_lookup_type(reg, d->parent_class); + if (owner && owner->is_interface) { + continue; + } + } CBMRegisteredFunc rf; memset(&rf, 0, sizeof(rf)); rf.qualified_name = d->qualified_name; diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 0e92150661..9666bd7250 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -220,6 +220,24 @@ static inline bool cbm_pipeline_node_is_callable_target(const cbm_gbuf_node_t *n strcmp(node->label, "Constructor") == 0 || strcmp(node->label, "Class") == 0; } +/* Whether the raw-name fallback must drop a blocked usage. An exact + * `import_map` hit is not a guess: the registry bound the name through the + * very import that blocked it, so the edge stays (an imported class passed to + * a decorator or used as a parameter type, #514). */ +static inline bool cbm_pipeline_blocked_usage_vetoed(const CBMUsage *usage, const char *strategy, + const cbm_gbuf_node_t *target) { + if (!usage->semantic_reference_blocked) { + return false; + } + if (usage->semantic_reference_local_shadow) { + return true; + } + if (strategy && strcmp(strategy, "import_map") == 0) { + return false; + } + return cbm_pipeline_node_is_callable_target(target); +} + static inline int cbm_pipeline_qn_class_method_tail_eq(const char *qn, const char *tail) { const char *qt = cbm_pipeline_qn_class_method_tail(qn); return qt && tail && strcmp(qt, tail) == 0; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 8f5c1efe32..6c9d8904be 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -886,6 +886,93 @@ static char *pxc_import_value_qn(CBMLanguage lang, const CBMFileResult *result, return qualified; } +/* JS/TS barrels. An import that lands on a Folder (`./svc` -> svc/index.ts) + * or on a module that only re-exports the name never reaches the declaring + * module, so `module.Name` is not in the registry and DI member calls on the + * imported type stay unresolved. Follow the barrel's own IMPORTS edges (its + * `export ... from` statements) to the one module that defines `name`. Those + * edges do not record which names each statement re-exports, so the + * definition node is the proof; two different definitions fail closed. */ +enum { PXC_BARREL_MAX_DEPTH = 8 }; + +static bool pxc_is_js_family(CBMLanguage lang) { + return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX; +} + +/* The graph node whose QN is cbm_pipeline_fqn_compute(project, rel_path, name), + * or NULL. The one place this pass computes a transient QN for a lookup. */ +static const cbm_gbuf_node_t *pxc_find_by_computed_qn(const cbm_gbuf_t *gbuf, const char *project, + const char *rel_path, const char *name) { + char *qn = cbm_pipeline_fqn_compute(project, rel_path, name); + const cbm_gbuf_node_t *n = qn ? cbm_gbuf_find_by_qn(gbuf, qn) : NULL; + free(qn); + return n; +} + +/* Node of `name` when the file at rel_path defines it, else NULL. */ +static const cbm_gbuf_node_t *pxc_defined_symbol(const cbm_gbuf_t *gbuf, const char *project_name, + const char *rel_path, const char *name) { + const cbm_gbuf_node_t *node = pxc_find_by_computed_qn(gbuf, project_name, rel_path, name); + if (node && node->label && strcmp(node->label, "Module") != 0 && + strcmp(node->label, "Folder") != 0 && strcmp(node->label, "File") != 0) { + return node; + } + return NULL; +} + +/* `visited` holds barrel file QNs already searched for `name`: barrels form + * DAGs (and cycles) where one lib index is reachable through many paths, and + * re-walking it on each path is exponential in the depth cap. */ +static const cbm_gbuf_node_t *pxc_barrel_symbol(const cbm_gbuf_t *gbuf, const char *project_name, + const cbm_gbuf_node_t *target, const char *name, + int depth, CBMHashTable *visited) { + if (!target || !target->label || !target->file_path || depth > PXC_BARREL_MAX_DEPTH) { + return NULL; + } + static const char *const index_names[] = {"index.ts", "index.tsx", "index.mts", + "index.js", "index.jsx", "index.mjs"}; + char rel[CBM_SZ_1K]; + const cbm_gbuf_node_t *file_node = NULL; + if (strcmp(target->label, "Module") == 0) { + snprintf(rel, sizeof(rel), "%s", target->file_path); + file_node = pxc_find_by_computed_qn(gbuf, project_name, rel, "__file__"); + } else if (strcmp(target->label, "Folder") == 0) { + for (size_t i = 0; i < sizeof(index_names) / sizeof(index_names[0]) && !file_node; i++) { + snprintf(rel, sizeof(rel), "%s/%s", target->file_path, index_names[i]); + file_node = pxc_find_by_computed_qn(gbuf, project_name, rel, "__file__"); + } + } + if (!file_node || cbm_ht_has(visited, file_node->qualified_name)) { + return NULL; + } + cbm_ht_set(visited, file_node->qualified_name, (void *)file_node); + const cbm_gbuf_node_t *defined = pxc_defined_symbol(gbuf, project_name, rel, name); + if (defined) { + return defined; + } + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + if (cbm_gbuf_find_edges_by_source_type(gbuf, file_node->id, "IMPORTS", &edges, &edge_count) != + 0) { + return NULL; + } + const cbm_gbuf_node_t *found = NULL; + for (int i = 0; i < edge_count; i++) { + const cbm_gbuf_node_t *next = cbm_gbuf_find_by_id(gbuf, edges[i]->target_id); + const cbm_gbuf_node_t *def = + pxc_barrel_symbol(gbuf, project_name, next, name, depth + 1, visited); + if (!def) { + continue; + } + if (found && found != def) { + return NULL; + } + found = def; + } + return found; +} + static bool pxc_import_map_has_local(const char *const *keys, int count, const char *local_name) { if (!keys || !local_name) return false; @@ -960,9 +1047,8 @@ int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, c const cbm_gbuf_edge_t **edges = NULL; int edge_count = 0; - char *file_qn = cbm_pipeline_fqn_compute(project_name, rel_path, "__file__"); - const cbm_gbuf_node_t *file_node = file_qn ? cbm_gbuf_find_by_qn(gbuf, file_qn) : NULL; - free(file_qn); + const cbm_gbuf_node_t *file_node = + pxc_find_by_computed_qn(gbuf, project_name, rel_path, "__file__"); if (file_node && cbm_gbuf_find_edges_by_source_type(gbuf, file_node->id, "IMPORTS", &edges, &edge_count) != 0) { edges = NULL; @@ -1001,7 +1087,23 @@ int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, c continue; memcpy(local, start, n); local[n] = '\0'; - char *value = pxc_import_value_qn(lang, result, local, target); + const cbm_gbuf_node_t *value_node = target; + if (pxc_is_js_family(lang) && target->label && target->file_path) { + const cbm_gbuf_node_t *direct = + strcmp(target->label, "Module") == 0 + ? pxc_defined_symbol(gbuf, project_name, target->file_path, local) + : NULL; + CBMHashTable *visited = direct ? NULL : cbm_ht_create(16); + if (visited) { + const cbm_gbuf_node_t *barrel = + pxc_barrel_symbol(gbuf, project_name, target, local, 0, visited); + cbm_ht_free(visited); + if (barrel) { + value_node = barrel; + } + } + } + char *value = pxc_import_value_qn(lang, result, local, value_node); if (!value) { free(local); continue; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index a8a9f36933..b35668ca2b 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3375,8 +3375,7 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, tgt->label)) { continue; } - if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || - cbm_pipeline_node_is_callable_target(tgt))) { + if (cbm_pipeline_blocked_usage_vetoed(usage, res.strategy, tgt)) { continue; } } diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index 9c4ace14b0..047820249c 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -1438,7 +1438,10 @@ char *cbm_pipeline_resolve_module(const cbm_pipeline_ctx_t *ctx, const char *sou if (amap) { char *aliased = cbm_path_alias_resolve(amap, module_path); if (aliased) { - char *qn = cbm_pipeline_fqn_module(ctx->project_name, aliased); + /* Like step 1: the alias resolver already removed an explicit + * file extension, so keep dotted basenames such as + * `cat.service` intact. */ + char *qn = cbm_pipeline_fqn_folder(ctx->project_name, aliased); free(aliased); return qn; } diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 9738f7198d..789e08237f 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -222,8 +222,7 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res tgt->label)) { continue; } - if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || - cbm_pipeline_node_is_callable_target(tgt))) { + if (cbm_pipeline_blocked_usage_vetoed(usage, res.strategy, tgt)) { continue; } } diff --git a/src/pipeline/path_alias.c b/src/pipeline/path_alias.c index ef999e14de..13dca61ff3 100644 --- a/src/pipeline/path_alias.c +++ b/src/pipeline/path_alias.c @@ -47,20 +47,23 @@ enum { /* ── Helpers ───────────────────────────────────────────────────── */ -/* Strip .ts/.tsx/.js/.jsx in place. Returns its argument. */ +/* Strip an explicit JS/TS module extension in place, keeping dotted + * basenames such as "cat.service". Same set as the relative resolver in + * fqn.c. Returns its argument. */ static char *strip_resolved_ext(char *path) { if (!path) { return path; } + static const char *const extensions[] = { + ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".json", + }; size_t len = strlen(path); - if (len > 3 && path[len - 3] == '.' && (path[len - 2] == 't' || path[len - 2] == 'j') && - path[len - 1] == 's') { - path[len - 3] = '\0'; - return path; - } - if (len > 4 && path[len - 4] == '.' && (path[len - 3] == 't' || path[len - 3] == 'j') && - path[len - 2] == 's' && path[len - 1] == 'x') { - path[len - 4] = '\0'; + for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); i++) { + size_t ext_len = strlen(extensions[i]); + if (len > ext_len && strcmp(path + len - ext_len, extensions[i]) == 0) { + path[len - ext_len] = '\0'; + break; + } } return path; } diff --git a/tests/repro/repro_issue514.c b/tests/repro/repro_issue514.c index 96f255045a..d2919ed0e3 100644 --- a/tests/repro/repro_issue514.c +++ b/tests/repro/repro_issue514.c @@ -197,7 +197,169 @@ TEST(repro_issue514_data_flow_surfaces_arg_expr) { PASS(); } +/* + * Sub-claim (B), minimal form from the issue thread: a NestJS controller + * calling a service injected through a constructor parameter property + * (`constructor(private readonly catService: CatService)`) must produce an + * inbound caller for CatService.findAll. + */ +static const RFile k_nest_files[] = { + {"src/cats/cat.service.ts", "export class CatService {\n" + " findAll(): string[] {\n" + " return [\"a\"];\n" + " }\n" + "}\n"}, + {"src/cats/cat.controller.ts", "import { CatService } from \"./cat.service\";\n" + "\n" + "export class CatController {\n" + " constructor(private readonly catService: CatService) {}\n" + "\n" + " list() {\n" + " return this.catService.findAll();\n" + " }\n" + "}\n"}, +}; + +/* Run trace_path inbound depth 1 on fn_name against the fixture project. + * rh_index_files restores CBM_CACHE_DIR after indexing; point the query at + * the fixture's cache so trace_path finds the project. */ +static char *rh514_trace_inbound(RProj *lp, const char *fn_name) { + char args[512]; + snprintf(args, sizeof(args), + "{\"function_name\":\"%s\"," + "\"project\":\"%s\"," + "\"direction\":\"inbound\"," + "\"depth\":1}", + fn_name, lp->project); + + const char *prior_cache_dir = getenv("CBM_CACHE_DIR"); + char *saved_cache_dir = prior_cache_dir ? strdup(prior_cache_dir) : NULL; + cbm_setenv("CBM_CACHE_DIR", lp->cachedir, 1); + char *resp = cbm_mcp_handle_tool(lp->srv, "trace_path", args); + if (saved_cache_dir) { + cbm_setenv("CBM_CACHE_DIR", saved_cache_dir, 1); + free(saved_cache_dir); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + return resp; +} + +TEST(repro_issue514_ctor_param_property_callers) { + RProj lp; + cbm_store_t *store = + rh_index_files(&lp, k_nest_files, (int)(sizeof(k_nest_files) / sizeof(k_nest_files[0]))); + ASSERT_NOT_NULL(store); + + char *resp = rh514_trace_inbound(&lp, "findAll"); + ASSERT_NOT_NULL(resp); + fprintf(stderr, " [514] trace_path inbound response: %.400s\n", resp); + + ASSERT_NULL(strstr(resp, "function not found")); + ASSERT_NOT_NULL(strstr(resp, "CatController.list")); + + free(resp); + rh_cleanup(&lp, store); + PASS(); +} + +/* Same DI shape, but the injected class is imported through a tsconfig + * `paths` alias, the norm in NestJS monorepos. The alias branch of import + * resolution stripped the dotted basename (`cat.service` -> `cat`), so the + * IMPORTS edge missed the module and the DI call lost its target. */ +static const RFile k_nest_alias_files[] = { + {"apps/api/tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\"./\",\"paths\":{\"@app/*\":[\"src/*\"]}}}\n"}, + {"apps/api/src/cats/cat.service.ts", "export class CatService {\n" + " findAll(): string[] {\n" + " return [\"a\"];\n" + " }\n" + "}\n"}, + {"apps/api/src/cats/cat.controller.ts", + "import { CatService } from \"@app/cats/cat.service\";\n" + "\n" + "export class CatController {\n" + " constructor(private readonly catService: CatService) {}\n" + "\n" + " list() {\n" + " return this.catService.findAll();\n" + " }\n" + "}\n"}, +}; + +TEST(repro_issue514_ctor_param_property_alias_import_callers) { + RProj lp; + cbm_store_t *store = rh_index_files( + &lp, k_nest_alias_files, (int)(sizeof(k_nest_alias_files) / sizeof(k_nest_alias_files[0]))); + ASSERT_NOT_NULL(store); + + char *resp = rh514_trace_inbound(&lp, "findAll"); + ASSERT_NOT_NULL(resp); + fprintf(stderr, " [514] trace_path inbound (alias) response: %.400s\n", resp); + + ASSERT_NULL(strstr(resp, "function not found")); + ASSERT_NOT_NULL(strstr(resp, "CatController.list")); + + free(resp); + rh_cleanup(&lp, store); + PASS(); +} + +/* Repositories are injected by string token and typed as an INTERFACE: + * `@Inject("order.OrderRepository") private readonly repo: OrderRepository`. + * Interface method signatures were not graph nodes, so the call had no + * target and trace_path found no callers. */ +static const RFile k_nest_iface_files[] = { + {"apps/api/tsconfig.json", "{\"compilerOptions\":{\"baseUrl\":\"./\",\"paths\":{\"@acme/order/" + "*\":[\"libs/order/src/*\"]}}}\n"}, + {"apps/api/libs/order/src/domain/order.repository.ts", + "export interface OrderRepository {\n" + " save(orderId: string): Promise;\n" + " findOneOrFail(orderId: string): Promise<{ id: string }>;\n" + "}\n"}, + {"apps/api/libs/order/src/application/command/handler/cancel-order.handler.ts", + "import { Inject } from \"@nestjs/common\";\n" + "import { OrderRepository } from \"@acme/order/domain/order.repository\";\n" + "\n" + "export class CancelOrderHandler {\n" + " constructor(\n" + " @Inject(\"order.OrderRepository\")\n" + " private readonly orderRepository: OrderRepository,\n" + " ) {}\n" + "\n" + " async execute(orderId: string): Promise {\n" + " const order = await this.orderRepository.findOneOrFail(orderId);\n" + " await this.orderRepository.save(order.id);\n" + " }\n" + "}\n"}, +}; + +TEST(repro_issue514_inject_token_interface_callers) { + RProj lp; + cbm_store_t *store = rh_index_files( + &lp, k_nest_iface_files, (int)(sizeof(k_nest_iface_files) / sizeof(k_nest_iface_files[0]))); + ASSERT_NOT_NULL(store); + + char *save = rh514_trace_inbound(&lp, "save"); + char *find = rh514_trace_inbound(&lp, "findOneOrFail"); + ASSERT_NOT_NULL(save); + ASSERT_NOT_NULL(find); + fprintf(stderr, " [514] trace_path inbound save: %.400s\n", save); + fprintf(stderr, " [514] trace_path inbound findOneOrFail: %.400s\n", find); + + ASSERT_NOT_NULL(strstr(save, "CancelOrderHandler.execute")); + ASSERT_NOT_NULL(strstr(find, "CancelOrderHandler.execute")); + + free(save); + free(find); + rh_cleanup(&lp, store); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────────────────── */ SUITE(repro_issue514) { RUN_TEST(repro_issue514_data_flow_surfaces_arg_expr); + RUN_TEST(repro_issue514_ctor_param_property_callers); + RUN_TEST(repro_issue514_ctor_param_property_alias_import_callers); + RUN_TEST(repro_issue514_inject_token_interface_callers); } diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 01d4419c1b..af7001def5 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -455,6 +455,66 @@ TEST(ei_typescript_dotted_relative_import_targets_source_module_issue1682) { PASS(); } +/* #514 follow-up: the tsconfig `paths` branch must preserve dotted basenames + * like the relative branch (#1682). It stripped `.service` from + * `@app/cats/cat.service`, missed the module, and bound the import by last + * segment to an unrelated node. */ +TEST(ei_typescript_dotted_alias_import_targets_source_module) { + static const char *service_path = "apps/api/src/cats/cat.service.ts"; + static const char *controller_path = "apps/api/src/cats/cat.controller.ts"; + static const EILangFile f[] = { + {"apps/api/tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\"./\",\"paths\":{\"@app/*\":[\"src/*\"]}}}\n"}, + {"apps/api/src/cats/cat.service.ts", "export class CatService {\n" + " findAll(): string[] {\n" + " return [\"a\"];\n" + " }\n" + "}\n"}, + {"apps/api/src/cats/cat.controller.ts", + "import { CatService } from \"@app/cats/cat.service\";\n" + "export class CatController {\n" + " constructor(private readonly catService: CatService) {}\n" + "}\n"}, + {"apps/api/src/other/service.ts", "export function service(): number {\n" + " return 1;\n" + "}\n"}, + }; + + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, (int)(sizeof(f) / sizeof(f[0]))); + ASSERT_NOT_NULL(store); + + int64_t controller_id = ei_node_id_for_file_label(store, lp.project, controller_path, "File"); + ASSERT_GT(controller_id, 0); + + cbm_edge_t *edges = NULL; + int edge_count = 0; + ASSERT_EQ( + cbm_store_find_edges_by_source_type(store, controller_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + + bool saw_import = false; + bool target_ok = false; + for (int i = 0; i < edge_count; i++) { + const char *props = edges[i].properties_json ? edges[i].properties_json : ""; + if (!strstr(props, "\"local_name\":\"CatService\"")) { + continue; + } + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + ASSERT_NOT_NULL(target); + ASSERT_EQ(cbm_store_find_node_by_id(store, edges[i].target_id, target), CBM_STORE_OK); + saw_import = true; + target_ok = target->file_path && strcmp(target->file_path, service_path) == 0; + cbm_store_free_nodes(target, 1); + } + cbm_store_free_edges(edges, edge_count); + ei_cleanup(&lp, store); + + ASSERT_TRUE(saw_import); + ASSERT_TRUE(target_ok); + PASS(); +} + /* TypeScript: default import `import helper from './util'`. */ TEST(ei_typescript_default_import) { static const EILangFile f[] = { @@ -1449,6 +1509,7 @@ SUITE(edge_imports) { /* ── GREEN GUARDS — TypeScript (must stay passing) ── */ RUN_TEST(ei_typescript_named_relative_import); RUN_TEST(ei_typescript_dotted_relative_import_targets_source_module_issue1682); + RUN_TEST(ei_typescript_dotted_alias_import_targets_source_module); RUN_TEST(ei_typescript_default_import); RUN_TEST(ei_typescript_namespace_import); RUN_TEST(ei_typescript_aliased_import); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index cde8aee452..2bd09d78de 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2349,6 +2349,37 @@ TEST(ts_interface) { PASS(); } +/* #514: interface members are method_signature nodes, not method_definition, + * so they were never emitted and calls through an interface-typed field had + * no target. Abstract class members likewise. Signatures inside type literals + * and class overloads must not become defs. */ +TEST(ts_interface_method_signatures_are_methods) { + CBMFileResult *r = extract("export interface OrderRepository {\n" + " save(orderId: string): Promise;\n" + " findOneOrFail(orderId: string): Promise<{ id: string }>;\n" + "}\n" + "export abstract class BaseRepo {\n" + " abstract flush(): void;\n" + "}\n" + "export class Impl {\n" + " load(a: string): void;\n" + " load(a: string, b?: number): void {}\n" + "}\n" + "export type Shape = { area(): number };\n", + CBM_LANG_TYPESCRIPT, "t", "order.repository.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Interface", "OrderRepository")); + ASSERT_EQ(count_defs_named(r, "Method", "save"), 1); + ASSERT_EQ(count_defs_named(r, "Method", "findOneOrFail"), 1); + ASSERT_EQ(count_defs_named(r, "Method", "flush"), 1); + ASSERT_EQ(count_defs_named(r, "Method", "load"), 1); + ASSERT_EQ(count_defs_named(r, "Method", "area"), 0); + ASSERT_EQ(count_defs_with_label(r, "Function"), 0); + cbm_free_result(r); + PASS(); +} + /* --- TSX component --- */ TEST(tsx_component) { CBMFileResult *r = extract( @@ -9426,6 +9457,7 @@ SUITE(extraction) { RUN_TEST(cuda_kernel); RUN_TEST(python_decorator); RUN_TEST(ts_interface); + RUN_TEST(ts_interface_method_signatures_are_methods); RUN_TEST(tsx_component); RUN_TEST(lua_table_method); RUN_TEST(emacs_lisp_defun); diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 91844f67e4..0d5b022c79 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1272,6 +1272,228 @@ TEST(lrp_ts_s8_field_type_hint) { PASS(); } +/* Barrels: a type imported through an index.ts that re-exports it must resolve + * to the defining module. Covers a dotted folder imported by alias (B1), a lib + * barrel with a named re-export (B2), a relative import of a barrel folder + * (B3) and a nested `export *` barrel (B4). The import used to stop at the + * Folder/barrel module, so every DI call below had no CALLS edge. */ +TEST(lrp_ts_barrel_reexport_member_calls) { + static const LRP_File f[] = { + {"apps/api/tsconfig.json", "{\"compilerOptions\":{\"baseUrl\":\"./\",\"paths\":{" + "\"@acme/shipping\":[\"libs/shipping/src\"]," + "\"@acme/shipping/*\":[\"libs/shipping/src/*\"]}}}\n"}, + {"apps/api/libs/shipping/src/application/service/label-print.service/" + "label-print.service.ts", + "import { LabelPrintParams } from \"./label-print.interface\";\n" + "export class LabelPrintService {\n" + " printStream(params: LabelPrintParams): string {\n" + " return this.printWithStats(params).body;\n" + " }\n" + " printWithStats(params: LabelPrintParams): { body: string; pages: number } {\n" + " return { body: params.shipmentId, pages: 1 };\n" + " }\n" + "}\n"}, + {"apps/api/libs/shipping/src/application/service/label-print.service/" + "label-print.interface.ts", + "export interface LabelPrintParams {\n" + " shipmentId: string;\n" + "}\n"}, + {"apps/api/libs/shipping/src/application/service/label-print.service/index.ts", + "export * from \"./label-print.service\";\n" + "export * from \"./label-print.interface\";\n"}, + {"apps/api/libs/shipping/src/domain/tracking/tracking-code.service.ts", + "export class TrackingCodeService {\n" + " generate(shipmentId: string): string {\n" + " return shipmentId;\n" + " }\n" + "}\n"}, + {"apps/api/libs/shipping/src/domain/tracking/index.ts", + "export * from \"./tracking-code.service\";\n"}, + {"apps/api/libs/shipping/src/domain/carrier.service.ts", + "export class CarrierService {\n" + " quote(weightKg: number): number {\n" + " return weightKg * 2;\n" + " }\n" + "}\n"}, + {"apps/api/libs/shipping/src/index.ts", + "export * from \"./domain/tracking\";\n" + "export {\n" + " CarrierService,\n" + "} from \"./domain/carrier.service\";\n" + "export type { LabelPrintParams } from \"./application/service/label-print.service\";\n"}, + {"apps/api/libs/invoice/src/application/command/handler/ship-invoice.handler.ts", + "import { LabelPrintService } from " + "\"@acme/shipping/application/service/label-print.service\";\n" + "import { CarrierService, TrackingCodeService } from \"@acme/shipping\";\n" + "export class ShipInvoiceHandler {\n" + " constructor(\n" + " private readonly labelPrintService: LabelPrintService,\n" + " private readonly carrierService: CarrierService,\n" + " private readonly trackingCodeService: TrackingCodeService,\n" + " ) {}\n" + " execute(shipmentId: string): string {\n" + " const cost = this.carrierService.quote(3);\n" + " const code = this.trackingCodeService.generate(shipmentId);\n" + " const label = this.labelPrintService.printStream({ shipmentId });\n" + " return `${label}:${code}:${cost}`;\n" + " }\n" + "}\n"}, + {"apps/api/libs/invoice/src/application/service/label-preview.service.ts", + "import { LabelPrintService } from " + "\"../../../../shipping/src/application/service/label-print.service\";\n" + "export class LabelPreviewService {\n" + " constructor(private readonly labelPrintService: LabelPrintService) {}\n" + " preview(shipmentId: string): number {\n" + " return this.labelPrintService.printWithStats({ shipmentId }).pages;\n" + " }\n" + "}\n"}, + }; + LRP_Proj lp; + cbm_store_t *store = lrp_index(&lp, f, (int)(sizeof(f) / sizeof(f[0]))); + ASSERT_NOT_NULL(store); + + int b1 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "ShipInvoiceHandler.execute", + "LabelPrintService.printStream"); + int b2 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "ShipInvoiceHandler.execute", + "CarrierService.quote"); + int b3 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "LabelPreviewService.preview", + "LabelPrintService.printWithStats"); + int b4 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "ShipInvoiceHandler.execute", + "TrackingCodeService.generate"); + int self = + lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "LabelPrintService.printStream", + "LabelPrintService.printWithStats"); + printf(" [ts/barrel] B1=%d B2=%d B3=%d B4=%d self=%d\n", b1, b2, b3, b4, self); + lrp_cleanup(&lp, store); + + ASSERT_EQ(b1, 1); + ASSERT_EQ(b2, 1); + ASSERT_EQ(b3, 1); + ASSERT_EQ(b4, 1); + ASSERT_EQ(self, 1); + PASS(); +} + +/* CQRS handlers name their event/command class only as a decorator argument, + * a method parameter type and an `ofType(X)` argument. Each is an imported + * class reference. The usage fallback vetoed every import-bound reference to + * a Class (treated as callable), so "who handles this event" had no edge. */ +TEST(lrp_ts_usage_imported_class_in_decorator_param_and_call_arg) { + static const LRP_File f[] = { + {"apps/api/tsconfig.json", "{\"compilerOptions\":{\"baseUrl\":\"./\",\"paths\":{" + "\"@acme/shipping/*\":[\"libs/shipping/src/*\"]," + "\"@acme/shared/*\":[\"libs/shared/src/*\"]}}}\n"}, + {"apps/api/libs/shipping/src/domain/event/shipment-dispatched.event.ts", + "export class ShipmentDispatchedEvent {\n" + " constructor(public readonly shipmentId: string) {}\n" + "}\n"}, + {"apps/api/libs/shipping/src/domain/event/shipment-returned.event.ts", + "export class ShipmentReturnedEvent {\n" + " constructor(public readonly shipmentId: string) {}\n" + "}\n"}, + {"apps/api/libs/shipping/src/application/command/dispatch-shipment.command.ts", + "export class DispatchShipmentCommand {\n" + " constructor(public readonly shipmentId: string) {}\n" + "}\n"}, + {"apps/api/libs/shared/src/traced-events-handler.decorator.ts", + "export const TracedEventsHandler = (...events: unknown[]): ClassDecorator => {\n" + " return (target) => {\n" + " void events;\n" + " void target;\n" + " };\n" + "};\n"}, + {"apps/api/libs/invoice/src/application/event/handler/" + "bill-on-shipment-dispatched.handler.ts", + "import { IEventHandler } from \"@nestjs/cqrs\";\n" + "import { ShipmentDispatchedEvent } from " + "\"@acme/shipping/domain/event/shipment-dispatched.event\";\n" + "import { TracedEventsHandler } from \"@acme/shared/traced-events-handler.decorator\";\n" + "@TracedEventsHandler(ShipmentDispatchedEvent)\n" + "export class BillOnShipmentDispatchedHandler implements " + "IEventHandler {\n" + " async handle(event: ShipmentDispatchedEvent): Promise {\n" + " void event.shipmentId;\n" + " }\n" + "}\n"}, + {"apps/api/libs/invoice/src/application/event/handler/" + "notify-on-shipment-dispatched.handler.ts", + "import { EventsHandler, IEventHandler } from \"@nestjs/cqrs\";\n" + "import { ShipmentDispatchedEvent } from " + "\"@acme/shipping/domain/event/shipment-dispatched.event\";\n" + "import { ShipmentReturnedEvent } from " + "\"@acme/shipping/domain/event/shipment-returned.event\";\n" + "@EventsHandler(ShipmentDispatchedEvent, ShipmentReturnedEvent)\n" + "export class NotifyOnShipmentDispatchedHandler\n" + " implements IEventHandler\n" + "{\n" + " handle(event: ShipmentDispatchedEvent | ShipmentReturnedEvent): void {\n" + " void event;\n" + " }\n" + "}\n"}, + {"apps/api/libs/shipping/src/application/command/handler/dispatch-shipment.handler.ts", + "import { CommandHandler, ICommandHandler } from \"@nestjs/cqrs\";\n" + "import { DispatchShipmentCommand } from " + "\"@acme/shipping/application/command/dispatch-shipment.command\";\n" + "@CommandHandler(DispatchShipmentCommand)\n" + "export class DispatchShipmentHandler implements " + "ICommandHandler {\n" + " async execute(command: DispatchShipmentCommand): Promise {\n" + " void command.shipmentId;\n" + " }\n" + "}\n"}, + {"apps/api/libs/shipping/src/application/saga/shipment.saga.ts", + "import { ICommand, ofType, Saga } from \"@nestjs/cqrs\";\n" + "import { Observable, map } from \"rxjs\";\n" + "import { ShipmentReturnedEvent } from " + "\"@acme/shipping/domain/event/shipment-returned.event\";\n" + "import { DispatchShipmentCommand } from " + "\"@acme/shipping/application/command/dispatch-shipment.command\";\n" + "export class ShipmentSaga {\n" + " @Saga()\n" + " redispatch = (events$: Observable): Observable =>\n" + " events$.pipe(\n" + " ofType(ShipmentReturnedEvent),\n" + " map((event) => new DispatchShipmentCommand(event.shipmentId)),\n" + " );\n" + "}\n"}, + }; + LRP_Proj lp; + cbm_store_t *store = lrp_index(&lp, f, (int)(sizeof(f) / sizeof(f[0]))); + ASSERT_NOT_NULL(store); + + int deco = lrp_exact_edge_by_qn_suffix(store, lp.project, "USAGE", + "bill-on-shipment-dispatched.handler", + "ShipmentDispatchedEvent"); + int g1a = lrp_exact_edge_by_qn_suffix(store, lp.project, "USAGE", + "BillOnShipmentDispatchedHandler.handle", + "ShipmentDispatchedEvent"); + int g1b_dispatched = lrp_exact_edge_by_qn_suffix(store, lp.project, "USAGE", + "NotifyOnShipmentDispatchedHandler.handle", + "ShipmentDispatchedEvent"); + int g1b_returned = lrp_exact_edge_by_qn_suffix(store, lp.project, "USAGE", + "NotifyOnShipmentDispatchedHandler.handle", + "ShipmentReturnedEvent"); + int g1c = lrp_exact_edge_by_qn_suffix( + store, lp.project, "USAGE", "DispatchShipmentHandler.execute", "DispatchShipmentCommand"); + int g1d = lrp_exact_edge_by_qn_suffix(store, lp.project, "USAGE", "ShipmentSaga.redispatch", + "ShipmentReturnedEvent"); + int negative = lrp_exact_edge_by_qn_suffix(store, lp.project, "USAGE", + "BillOnShipmentDispatchedHandler.handle", + "ShipmentReturnedEvent"); + printf(" [ts/usage-class] deco=%d g1a=%d g1b=%d/%d g1c=%d g1d=%d negative=%d\n", deco, g1a, + g1b_dispatched, g1b_returned, g1c, g1d, negative); + lrp_cleanup(&lp, store); + + ASSERT_GTE(deco, 1); + ASSERT_GTE(g1a, 1); + ASSERT_GTE(g1b_dispatched, 1); + ASSERT_GTE(g1b_returned, 1); + ASSERT_GTE(g1c, 1); + ASSERT_GTE(g1d, 1); + ASSERT_EQ(negative, 0); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════════ * ─── GROUP 7: JAVA (lsp_cross NOT WIRED) ───────────────────────────── * ══════════════════════════════════════════════════════════════════════ @@ -1919,6 +2141,8 @@ SUITE(lsp_resolution_probe) { RUN_TEST(lrp_ts_s6_inherited_method); RUN_TEST(lrp_ts_s7_generic); RUN_TEST(lrp_ts_s8_field_type_hint); + RUN_TEST(lrp_ts_barrel_reexport_member_calls); + RUN_TEST(lrp_ts_usage_imported_class_in_decorator_param_and_call_arg); /* ── Java (lsp_cross NOT WIRED) — S1 GREEN, S2–S8 RED reproductions ── */ RUN_TEST(lrp_java_s1_crossfile_call); diff --git a/tests/test_path_alias.c b/tests/test_path_alias.c index 9158c04535..0361227b49 100644 --- a/tests/test_path_alias.c +++ b/tests/test_path_alias.c @@ -155,6 +155,22 @@ TEST(path_alias_strips_ext) { PASS(); } +/* Same extension set as the relative resolver (#1682); dotted basenames + * without a module extension stay intact. */ +TEST(path_alias_strips_module_ext_keeps_dotted_basename) { + cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*"); + char *r = cbm_path_alias_resolve(m, "@/lib/util.mjs"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(r, "src/lib/util"); + free(r); + r = cbm_path_alias_resolve(m, "@/cats/cat.service"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(r, "src/cats/cat.service"); + free(r); + free_map(m); + PASS(); +} + /* ── baseUrl fallback for non-relative, non-package imports ────── */ TEST(path_alias_baseurl_fallback) { @@ -557,6 +573,7 @@ void suite_path_alias(void) { RUN_TEST(path_alias_specificity_longest_first); RUN_TEST(path_alias_exact_match); RUN_TEST(path_alias_strips_ext); + RUN_TEST(path_alias_strips_module_ext_keeps_dotted_basename); RUN_TEST(path_alias_baseurl_fallback); RUN_TEST(path_alias_null_safety); RUN_TEST(path_alias_find_for_file_nearest_ancestor); diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index 12b4ef2bd0..bd5c7ad0ec 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -511,6 +511,32 @@ TEST(tslsp_class_super_call) { PASS(); } +/* Issue #514: constructor parameter properties (NestJS DI) are class fields. */ +TEST(tslsp_class_ctor_param_property) { + CBMFileResult *r = extract_ts("class CatService { findAll(): string[] { return []; } }\n" + "class CatController {\n" + " constructor(private readonly catService: CatService) {}\n" + " findAll() { return this.catService.findAll(); }\n" + "}\n"); + ASSERT_NOT_NULL(r); + ASSERT_GTE(require_resolved(r, "CatController.findAll", "CatService.findAll"), 0); + cbm_free_result(r); + PASS(); +} + +/* A constructor parameter without a modifier is not a field. */ +TEST(tslsp_class_ctor_plain_param_not_field) { + CBMFileResult *r = extract_ts("class CatService { findAll(): string[] { return []; } }\n" + "class CatController {\n" + " constructor(catService: CatService) {}\n" + " list() { return this.catService.findAll(); }\n" + "}\n"); + ASSERT_NOT_NULL(r); + ASSERT_LT(find_resolved(r, "CatController.list", "CatService.findAll"), 0); + cbm_free_result(r); + PASS(); +} + /* ── Category 10: interface dispatch ──────────────────────────────────────── */ TEST(tslsp_iface_method) { @@ -2681,6 +2707,50 @@ TEST(tslsp_crossfile_no_def_match) { PASS(); } +/* Issue #514: NestJS controller injecting a service from another file via a + * constructor parameter property. */ +TEST(tslsp_crossfile_ctor_param_property) { + const char *source = "import { CatService } from './cat.service';\n" + "export class CatController {\n" + " constructor(private readonly catService: CatService) {}\n" + " findAll() { return this.catService.findAll(); }\n" + "}\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.main.CatController", + .short_name = "CatController", + .label = "Class", + .def_module_qn = "test.main"}, + {.qualified_name = "test.main.CatController.findAll", + .short_name = "findAll", + .label = "Method", + .def_module_qn = "test.main", + .receiver_type = "test.main.CatController"}, + {.qualified_name = "test.cat.service.CatService", + .short_name = "CatService", + .label = "Class", + .def_module_qn = "test.cat.service"}, + {.qualified_name = "test.cat.service.CatService.findAll", + .short_name = "findAll", + .label = "Method", + .def_module_qn = "test.cat.service", + .receiver_type = "test.cat.service.CatService"}, + }; + const char *imp_names[] = {"CatService"}; + const char *imp_qns[] = {"test.cat.service"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + + cbm_run_ts_lsp_cross(&arena, source, (int)strlen(source), "test.main", false, false, false, + defs, 4, imp_names, imp_qns, 1, NULL, &out); + + ASSERT_GTE(find_resolved_arr(&out, "CatController.findAll", "CatService.findAll"), 0); + + cbm_arena_destroy(&arena); + PASS(); +} + /* ── More stdlib (Date, RegExp, Error, Math) ──────────────────────────────── */ TEST(tslsp_stdlib_date_toISOString) { @@ -4773,6 +4843,8 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_class_this_dispatch); RUN_TEST(tslsp_class_inheritance_method); RUN_TEST(tslsp_class_super_call); + RUN_TEST(tslsp_class_ctor_param_property); + RUN_TEST(tslsp_class_ctor_plain_param_not_field); /* Category 10: interface */ RUN_TEST(tslsp_iface_method); @@ -4994,6 +5066,7 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_crossfile_function_call); RUN_TEST(tslsp_crossfile_chain_through_return); RUN_TEST(tslsp_crossfile_no_def_match); + RUN_TEST(tslsp_crossfile_ctor_param_property); /* More stdlib */ RUN_TEST(tslsp_stdlib_date_toISOString);