diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index b0216eddb..d455e16d4 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -407,15 +407,22 @@ typedef enum { CBM_IMPORT_KIND_CONST, // PHP `use const A\B` } CBMImportKind; +typedef enum { + CBM_REEXPORT_NONE = 0, + CBM_REEXPORT_NAMED, + CBM_REEXPORT_STAR, +} CBMReexportKind; + typedef struct { - const char *local_name; // local alias or name - const char *module_path; // resolved module path / QN - const char *member; // ES named import: the exported name the local binds (`get` in - // `import { get as navigate } from "axios"`); NULL otherwise - bool is_default; // ES default import (`import X from "Y"`), JS/TS only (#1916) - bool type_checking_only; // Python: inside a module-level `if TYPE_CHECKING:` block -- - // binds the name for annotations, never at runtime (#1277) - CBMImportKind kind; // CBM_IMPORT_KIND_DEFAULT unless the syntax names a member kind + const char *local_name; // local alias or name + const char *module_path; // resolved module path / QN + const char *member; // ES named import: the exported name the local binds (`get` in + // `import { get as navigate } from "axios"`); NULL otherwise + bool is_default; // ES default import (`import X from "Y"`), JS/TS only (#1916) + bool type_checking_only; // Python: inside a module-level `if TYPE_CHECKING:` block -- + // binds the name for annotations, never at runtime (#1277) + CBMImportKind kind; // CBM_IMPORT_KIND_DEFAULT unless the syntax names a member kind + CBMReexportKind reexport; // ES `export {a as b} from` or `export * from`; NONE otherwise } CBMImport; /* True when an import binds its local name at runtime. A Python import under diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index c19debd80..bd69ba06f 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -1814,6 +1814,51 @@ static bool process_commonjs_require(CBMExtractCtx *ctx, TSNode call) { return true; } +static void process_es_export_statement(CBMExtractCtx *ctx, TSNode node) { + CBMArena *a = ctx->arena; + TSNode src = ts_node_child_by_field_name(node, TS_FIELD("source")); + if (ts_node_is_null(src)) { + return; + } + char *path = strip_quotes(a, cbm_node_text(a, src, ctx->source)); + if (!path || !path[0]) { + return; + } + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode child = ts_node_child(node, i); + const char *kind = ts_node_type(child); + if (strcmp(kind, "export_clause") == 0) { + uint32_t sc = ts_node_child_count(child); + for (uint32_t j = 0; j < sc; j++) { + TSNode spec = ts_node_child(child, j); + if (strcmp(ts_node_type(spec), "export_specifier") != 0) { + continue; + } + TSNode original = ts_node_child_by_field_name(spec, TS_FIELD("name")); + TSNode alias = ts_node_child_by_field_name(spec, TS_FIELD("alias")); + if (ts_node_is_null(original)) { + continue; + } + const char *member = cbm_node_text(a, original, ctx->source); + const char *exported = + ts_node_is_null(alias) ? member : cbm_node_text(a, alias, ctx->source); + CBMImport imp = {.local_name = exported, + .module_path = path, + .member = member, + .reexport = CBM_REEXPORT_NAMED}; + cbm_imports_push(&ctx->result->imports, a, imp); + } + return; + } + if (strcmp(kind, "*") == 0) { + CBMImport imp = {.local_name = "*", .module_path = path, .reexport = CBM_REEXPORT_STAR}; + cbm_imports_push(&ctx->result->imports, a, imp); + return; + } + } +} + static void walk_es_imports(CBMExtractCtx *ctx, TSNode root) { TSNodeStack stack; ts_nstack_init(&stack, ctx, CBM_SZ_512); @@ -1829,18 +1874,7 @@ static void walk_es_imports(CBMExtractCtx *ctx, TSNode root) { push_children = false; } } else if (strcmp(kind, "export_statement") == 0) { - /* Re-export: `export { x } from './mod'` / `export * from './mod'`. - * It carries a `source` string just like an import and creates the - * same module dependency. */ - TSNode src = ts_node_child_by_field_name(node, TS_FIELD("source")); - if (!ts_node_is_null(src)) { - char *path = strip_quotes(ctx->arena, cbm_node_text(ctx->arena, src, ctx->source)); - if (path && path[0]) { - CBMImport imp = {.local_name = path_last(ctx->arena, path), - .module_path = path}; - cbm_imports_push(&ctx->result->imports, ctx->arena, imp); - } - } + process_es_export_statement(ctx, node); } else if (strcmp(kind, "call_expression") == 0) { /* CommonJS require() — only consume the node if we recognized * it as a require call; otherwise keep traversing the children. */ diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index a673e7539..212dc47d8 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -25,6 +25,7 @@ enum { #include #include "pipeline/pipeline_internal.h" #include "pipeline/lsp_resolve.h" +#include "pipeline/pass_lsp_cross.h" #include "pipeline/laravel_routing.h" #include "graph_buffer/graph_buffer.h" #include "foundation/log.h" @@ -131,12 +132,36 @@ static const cbm_gbuf_node_t *pc_find_by_computed_qn(const cbm_gbuf_t *gbuf, con return n; } +/* The import map of rel_path. *out_vals_owned says who owns the value strings: + * the JS/TS map is cbm_pxc_build_import_map's (values owned), every other + * path borrows its values from the graph buffer. free_import_map takes the + * same flag. */ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, const CBMFileResult *result, const char ***out_keys, - const char ***out_vals, int *out_count) { + const char ***out_vals, int *out_count, bool *out_vals_owned) { *out_keys = NULL; *out_vals = NULL; *out_count = 0; + *out_vals_owned = false; + + /* JS/TS: the cross-file map follows re-export chains to the defining + * file, and every value it holds is the QN of a graph node. */ + CBMLanguage lang = cbm_language_for_filename(rel_path); + if (lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX) { + const char **keys = NULL; + const char **vals = NULL; + int count = 0; + cbm_pxc_build_import_map(ctx->gbuf, ctx->project_name, rel_path, lang, result, &keys, &vals, + &count); + if (count > 0) { + *out_keys = keys; + *out_vals = vals; + *out_count = count; + *out_vals_owned = true; + return 0; + } + cbm_pxc_free_import_map(keys, vals, count); + } /* Fast path: build from cached extraction via the same resolver used for * IMPORTS edges. Do NOT use cbm_pipeline_fqn_module(module_path) — Python @@ -225,7 +250,11 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, return 0; } -static void free_import_map(const char **keys, const char **vals, int count) { +static void free_import_map(const char **keys, const char **vals, int count, bool vals_owned) { + if (vals_owned) { + cbm_pxc_free_import_map(keys, vals, count); + return; + } if (keys) { for (int i = 0; i < count; i++) { free((void *)keys[i]); @@ -1536,7 +1565,8 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file const char **imp_keys = NULL; const char **imp_vals = NULL; int imp_count = 0; - build_import_map(ctx, rel, result, &imp_keys, &imp_vals, &imp_count); + bool imp_vals_owned = false; + build_import_map(ctx, rel, result, &imp_keys, &imp_vals, &imp_count, &imp_vals_owned); cbm_php_vendor_names_t php_vendor; cbm_pipeline_php_vendor_names_build(ctx->gbuf, ctx->project_name, rel, result, &php_vendor); @@ -1574,7 +1604,7 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file cbm_free(CBM_MEM_CLASS_RESOLVE, evidence); free(module_qn); cbm_pipeline_php_vendor_names_free(&php_vendor); - free_import_map(imp_keys, imp_vals, imp_count); + free_import_map(imp_keys, imp_vals, imp_count, imp_vals_owned); if (result_owned) { cbm_free_result(result); } @@ -1710,7 +1740,9 @@ void cbm_pipeline_pass_fastapi_depends(cbm_pipeline_ctx_t *ctx, const cbm_file_i const char **imp_keys = NULL; const char **imp_vals = NULL; int imp_count = 0; - build_import_map(ctx, files[i].rel_path, result, &imp_keys, &imp_vals, &imp_count); + bool imp_vals_owned = false; + build_import_map(ctx, files[i].rel_path, result, &imp_keys, &imp_vals, &imp_count, + &imp_vals_owned); for (int d = 0; d < result->defs.count; d++) { CBMDefinition *def = &result->defs.items[d]; @@ -1729,7 +1761,7 @@ void cbm_pipeline_pass_fastapi_depends(cbm_pipeline_ctx_t *ctx, const cbm_file_i } free(module_qn); - free_import_map(imp_keys, imp_vals, imp_count); + free_import_map(imp_keys, imp_vals, imp_count, imp_vals_owned); free(source); } diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 7c7aa438f..21dfbb0c6 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -683,7 +683,19 @@ static int create_import_edges_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileRe char esc_ln[CBM_SZ_128]; cbm_json_escape(esc_ln, sizeof(esc_ln), imp->local_name ? imp->local_name : ""); char imp_props[CBM_SZ_256]; - snprintf(imp_props, sizeof(imp_props), "{\"local_name\":\"%s\"}", esc_ln); + if (imp->reexport == CBM_REEXPORT_NAMED && imp->member) { + char esc_member[CBM_SZ_128]; + cbm_json_escape(esc_member, sizeof(esc_member), imp->member); + snprintf(imp_props, sizeof(imp_props), + "{\"local_name\":\"%s\",\"reexport\":\"named\"," + "\"imported\":\"%s\"}", + esc_ln, esc_member); + } else if (imp->reexport == CBM_REEXPORT_STAR) { + snprintf(imp_props, sizeof(imp_props), + "{\"local_name\":\"%s\",\"reexport\":\"star\"}", esc_ln); + } else { + snprintf(imp_props, sizeof(imp_props), "{\"local_name\":\"%s\"}", esc_ln); + } cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, target->id, "IMPORTS", imp_props); count++; } diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index a0a769fd1..3c32068d3 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -1419,6 +1419,178 @@ 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 only the barrel's re-export IMPORTS + * edges, preserving `export { original as public }` names across each hop. + * 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 definition of `name` in the file at rel_path, else NULL. The symbol QN + * cbm_pipeline_fqn_compute would give is the file's symbol prefix (a byte + * prefix of its File node QN: extension and a trailing index segment are + * only ever cut from the end) plus `.name`, so it is built on the stack and + * the lookup allocates nothing. */ +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 *file_node = cbm_pipeline_file_node(gbuf, project_name, rel_path); + if (!file_node || !file_node->qualified_name) { + return NULL; + } + size_t prefix_len = cbm_pipeline_fqn_symbol_prefix_len(project_name, rel_path); + if (prefix_len == 0 || prefix_len >= strlen(file_node->qualified_name) || + file_node->qualified_name[prefix_len] != '.') { + return NULL; + } + char qn[CBM_SZ_1K]; + int wrote = + snprintf(qn, sizeof(qn), "%.*s.%s", (int)prefix_len, file_node->qualified_name, name); + if (wrote <= 0 || (size_t)wrote >= sizeof(qn)) { + return NULL; + } + const cbm_gbuf_node_t *node = cbm_gbuf_find_by_qn(gbuf, qn); + if (node && node->qualified_name && node->label && strcmp(node->label, "Module") != 0 && + strcmp(node->label, "Folder") != 0 && strcmp(node->label, "File") != 0) { + return node; + } + return NULL; +} + +static bool pxc_edge_string_prop(const char *json, const char *key, char *out, size_t out_sz) { + if (!json || !key || !out || out_sz == 0) { + return false; + } + char needle[CBM_SZ_64]; + int wrote = snprintf(needle, sizeof(needle), "\"%s\":\"", key); + if (wrote <= 0 || (size_t)wrote >= sizeof(needle)) { + return false; + } + const char *start = strstr(json, needle); + if (!start) { + return false; + } + start += (size_t)wrote; + const char *end = strchr(start, '"'); + size_t len = end ? (size_t)(end - start) : 0; + if (!end || len == 0 || len >= out_sz || memchr(start, '\\', len)) { + return false; + } + memcpy(out, start, len); + out[len] = '\0'; + return true; +} + +static void pxc_free_visit_key(const char *key, void *value, void *userdata) { + (void)key; + (void)userdata; + cbm_free(CBM_MEM_CLASS_OTHER, value); +} + +/* Free a `visited` table built by pxc_barrel_symbol and its owned keys. */ +static void pxc_barrel_visited_free(CBMHashTable *visited) { + cbm_ht_foreach(visited, pxc_free_visit_key, NULL); + cbm_ht_free(visited); +} + +/* `visited` holds (barrel file QN, name) pairs already searched: 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. The result is a + * graph node: the caller borrows it for as long as the graph buffer lives. */ +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; + } + if (strcmp(target->label, "Module") != 0 && strcmp(target->label, "Folder") != 0 && + strcmp(target->label, "File") != 0) { + /* A named re-export edge reaches a definition directly when ordinary + * import resolution proved its imported member. The edge metadata has + * already matched the public/imported names at the caller above. */ + return target->qualified_name ? target : 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 = cbm_pipeline_file_node(gbuf, project_name, rel); + } 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 = cbm_pipeline_file_node(gbuf, project_name, rel); + } + } + if (!file_node || !file_node->qualified_name) { + return NULL; + } + /* Key on file and name: a named re-export renames the symbol, so one + * file can be searched for two names in one walk (`export *` and + * `export { a as b }` from the same module). The key owns its bytes and + * is the stored value; pxc_barrel_visited_free releases it. */ + size_t vk_len = strlen(file_node->qualified_name) + 1 + strlen(name) + 1; + char *visit_key = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, vk_len); + if (!visit_key) { + return NULL; + } + snprintf(visit_key, vk_len, "%s\x1f%s", file_node->qualified_name, name); + if (cbm_ht_has(visited, visit_key)) { + cbm_free(CBM_MEM_CLASS_OTHER, visit_key); + return NULL; + } + cbm_ht_set(visited, visit_key, visit_key); + 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++) { + char kind[CBM_SZ_16]; + char imported_name[CBM_SZ_256]; + if (!pxc_edge_string_prop(edges[i]->properties_json, "reexport", kind, sizeof(kind))) { + continue; + } + const char *next_name = name; + if (strcmp(kind, "named") == 0) { + char public_name[CBM_SZ_256]; + if (!pxc_edge_string_prop(edges[i]->properties_json, "local_name", public_name, + sizeof(public_name)) || + strcmp(public_name, name) != 0 || + !pxc_edge_string_prop(edges[i]->properties_json, "imported", imported_name, + sizeof(imported_name))) { + continue; + } + next_name = imported_name; + } else if (strcmp(kind, "star") != 0) { + continue; + } + 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, next_name, depth + 1, visited); + if (!def) { + continue; + } + if (found && strcmp(found->qualified_name, def->qualified_name) != 0) { + 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; @@ -1563,7 +1735,23 @@ int cbm_pxc_build_import_map_ex(const cbm_gbuf_t *gbuf, const char *project_name /* An annotation-only import (#1277) binds no CALLS name: it gets no value. */ bool binds = include_type_only || cbm_imports_local_binds_runtime_name(result ? &result->imports : NULL, local); - char *value = binds ? pxc_import_value_qn(lang, result, local, target) : NULL; + /* JS/TS: a Module that defines the name keeps its own QN; a barrel + * that only re-exports it stands for the definition it reaches. */ + const cbm_gbuf_node_t *value_node = target; + if (binds && pxc_is_js_family(lang) && target->label && target->file_path && + (strcmp(target->label, "Module") != 0 || + !pxc_defined_symbol(gbuf, project_name, target->file_path, local))) { + CBMHashTable *visited = cbm_ht_create(16); + const cbm_gbuf_node_t *def = + visited ? pxc_barrel_symbol(gbuf, project_name, target, local, 0, visited) : NULL; + if (visited) { + pxc_barrel_visited_free(visited); + } + if (def) { + value_node = def; + } + } + char *value = binds ? pxc_import_value_qn(lang, result, local, value_node) : NULL; if (!value) { free(local); continue; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e02d68c67..0a399171c 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1900,7 +1900,19 @@ static int create_imports_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *re char esc_ln[CBM_SZ_128]; cbm_json_escape(esc_ln, sizeof(esc_ln), imp->local_name ? imp->local_name : ""); char imp_props[CBM_SZ_256]; - snprintf(imp_props, sizeof(imp_props), "{\"local_name\":\"%s\"}", esc_ln); + if (imp->reexport == CBM_REEXPORT_NAMED && imp->member) { + char esc_member[CBM_SZ_128]; + cbm_json_escape(esc_member, sizeof(esc_member), imp->member); + snprintf(imp_props, sizeof(imp_props), + "{\"local_name\":\"%s\",\"reexport\":\"named\"," + "\"imported\":\"%s\"}", + esc_ln, esc_member); + } else if (imp->reexport == CBM_REEXPORT_STAR) { + snprintf(imp_props, sizeof(imp_props), + "{\"local_name\":\"%s\",\"reexport\":\"star\"}", esc_ln); + } else { + snprintf(imp_props, sizeof(imp_props), "{\"local_name\":\"%s\"}", esc_ln); + } cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, target->id, "IMPORTS", imp_props); count++; } diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index 7af626b2d..fe2ce0a55 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -1442,7 +1442,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/path_alias.c b/src/pipeline/path_alias.c index 7ef54c369..dc144a168 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 96f255045..abf2be625 100644 --- a/tests/repro/repro_issue514.c +++ b/tests/repro/repro_issue514.c @@ -197,7 +197,117 @@ 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(); +} + /* ── 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); } diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 21f433c31..efa1c1dd1 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -597,6 +597,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[] = { @@ -2496,6 +2556,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_imports.c b/tests/test_extraction_imports.c index 1b1e93d6c..002561ba7 100644 --- a/tests/test_extraction_imports.c +++ b/tests/test_extraction_imports.c @@ -1310,6 +1310,30 @@ TEST(es_named_import_member_survives_compaction) { PASS(); } +TEST(es_reexport_names_and_star_survive_compaction) { + CBMFileResult *r = do_extract("export { internal as publicName, plain } from './child.js';\n" + "export * from './nested.js';\n", + CBM_LANG_TYPESCRIPT, "barrel.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + cbm_result_compact(r); + const CBMImport *renamed = imp_by_local(r, "publicName"); + const CBMImport *plain = imp_by_local(r, "plain"); + const CBMImport *star = imp_by_local(r, "*"); + ASSERT_NOT_NULL(renamed); + ASSERT_NOT_NULL(plain); + ASSERT_NOT_NULL(star); + ASSERT_STR_EQ(renamed->module_path, "./child.js"); + ASSERT_STR_EQ(renamed->member, "internal"); + ASSERT_EQ(renamed->reexport, CBM_REEXPORT_NAMED); + ASSERT_STR_EQ(plain->member, "plain"); + ASSERT_EQ(plain->reexport, CBM_REEXPORT_NAMED); + ASSERT_STR_EQ(star->module_path, "./nested.js"); + ASSERT_EQ(star->reexport, CBM_REEXPORT_STAR); + cbm_free_result(r); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════════════ * SUITE declaration — do NOT register in test_main.c; the main workstream will * ═══════════════════════════════════════════════════════════════════════════ */ @@ -1353,4 +1377,5 @@ SUITE(extraction_imports) { /* ES named-import member (call resolution spells calls through it) */ RUN_TEST(es_named_import_member_survives_compaction); + RUN_TEST(es_reexport_names_and_star_survive_compaction); } diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 91844f67e..41579ec9b 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1272,6 +1272,148 @@ 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/domain/internal.service.ts", + "export function internalCall(): number { return 7; }\n"}, + {"apps/api/libs/shipping/src/domain/public.ts", + "export { internalCall as publicCall } from './internal.service.js';\n"}, + {"apps/api/libs/shipping/src/index.ts", + "export * from \"./domain/tracking\";\n" + "export {\n" + " CarrierService,\n" + "} from \"./domain/carrier.service\";\n" + "export { publicCall as apiCall } from './domain/public.js';\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 { apiCall, 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 renamed = apiCall();\n" + " const label = this.labelPrintService.printStream({ shipmentId });\n" + " return `${label}:${code}:${cost}:${renamed}`;\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 renamed = lrp_exact_edge_by_qn_suffix( + store, lp.project, "CALLS", "ShipInvoiceHandler.execute", "internal.service.internalCall"); + 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 renamed=%d self=%d\n", b1, b2, b3, b4, renamed, + self); + lrp_cleanup(&lp, store); + + ASSERT_EQ(b1, 1); + ASSERT_EQ(b2, 1); + ASSERT_EQ(b3, 1); + ASSERT_EQ(b4, 1); + ASSERT_EQ(renamed, 1); + ASSERT_EQ(self, 1); + PASS(); +} + +/* One barrel re-exports the same module twice: `export *` and a renamed + * `export { innerFn as renamedFn }`. The walk searches impl for renamedFn + * through the star edge first; it must still search impl for innerFn + * through the renamed edge, so the call binds to impl's innerFn. */ +TEST(lrp_ts_barrel_star_and_renamed_same_module) { + static const LRP_File f[] = { + {"src/lib/impl.ts", "export function innerFn(x: number): number { return x - 1; }\n" + "export function starFn(x: number): number { return x * 2; }\n"}, + {"src/lib/barrel.ts", "export * from './impl';\n" + "export { innerFn as renamedFn } from './impl';\n"}, + {"src/app.ts", "import { renamedFn, starFn } from './lib/barrel';\n" + "export function run(x: number): number {\n" + " return renamedFn(x) + starFn(x);\n" + "}\n"}, + }; + LRP_Proj lp; + cbm_store_t *store = lrp_index(&lp, f, (int)(sizeof(f) / sizeof(f[0]))); + ASSERT_NOT_NULL(store); + + int renamed = + lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "app.run", "impl.innerFn"); + int star = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "app.run", "impl.starFn"); + printf(" [ts/barrel-same-module] renamed=%d star=%d\n", renamed, star); + lrp_cleanup(&lp, store); + + ASSERT_EQ(renamed, 1); + ASSERT_EQ(star, 1); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════════ * ─── GROUP 7: JAVA (lsp_cross NOT WIRED) ───────────────────────────── * ══════════════════════════════════════════════════════════════════════ @@ -1919,6 +2061,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_barrel_star_and_renamed_same_module); /* ── 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 9158c0453..0361227b4 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);