diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c index b413727d5..9d0861155 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -282,20 +282,19 @@ static char *resolve_python_relative(char *buf, size_t buf_size, const char *mod return strdup(buf); } -/* Strip a trailing file extension from a segment (e.g. "helpers.ts" → "helpers"). - * Returns the new segment length. */ -static size_t strip_ext(const char *seg_start, size_t seg_len) { - const char *seg_end = seg_start + seg_len; - const char *dot = NULL; - for (const char *d = seg_end - FQN_SEP_LEN; d >= seg_start; d--) { - if (*d == '.') { - dot = d; - break; +/* Strip an explicit JS/TS module file extension while preserving dots that are + * part of an extensionless basename (e.g. "featureX.engine"). */ +static size_t strip_js_module_ext(const char *seg_start, size_t seg_len) { + static const char *const extensions[] = { + ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".json", + }; + for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); i++) { + size_t ext_len = strlen(extensions[i]); + if (seg_len > ext_len && + memcmp(seg_start + seg_len - ext_len, extensions[i], ext_len) == 0) { + return seg_len - ext_len; } } - if (dot && dot > seg_start) { - return (size_t)(dot - seg_start); - } return seg_len; } @@ -322,7 +321,7 @@ static char *resolve_js_relative(char *buf, size_t buf_size, const char *module_ continue; } if (*p == '\0') { - seg_len = strip_ext(seg_start, seg_len); + seg_len = strip_js_module_ext(seg_start, seg_len); } if (seg_len > 0 && !path_append_segment(buf, buf_size, seg_start, seg_len)) { return NULL; diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index a44864659..e185af29e 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -1397,7 +1397,11 @@ char *cbm_pipeline_resolve_module(const cbm_pipeline_ctx_t *ctx, const char *sou /* 1. Try relative import resolution (existing logic) */ char *resolved = cbm_pipeline_resolve_relative_import(source_rel, module_path); if (resolved) { - char *qn = cbm_pipeline_fqn_module(ctx->project_name, resolved); + /* The relative resolver has already removed an explicit JS/TS file + * extension. Treat the remaining path as a module path verbatim so a + * dotted extensionless basename such as `featureX.engine` is not + * stripped a second time by cbm_pipeline_fqn_module. */ + char *qn = cbm_pipeline_fqn_folder(ctx->project_name, resolved); free(resolved); return qn; } diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 8f34e95a7..dd511b331 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -351,6 +351,96 @@ TEST(ei_typescript_named_relative_import) { PASS(); } +/* #1682: extensionless dotted basenames are part of the module name. The + * resolver used to strip `.engine`, miss the module, and bind both imports to + * the same-named fixture Function in the sibling spec file. */ +TEST(ei_typescript_dotted_relative_import_targets_source_module_issue1682) { + static const char *engine_path = "packages/api/src/modules/featureX/featureX.engine.ts"; + static const char *consumer_path = "packages/api/src/modules/consumer/consumer.service.ts"; + static const EILangFile f[] = { + {"packages/api/src/modules/featureX/featureX.engine.ts", + "export interface SomeType { id: string; qty: number; }\n" + "export interface Evaluation { rateByItem: Record; }\n" + "export function helperB(configs: SomeType[], lines: SomeType[]): Evaluation {\n" + " return { rateByItem: { [lines[0].id]: lines[0].qty + configs.length } };\n" + "}\n"}, + {"packages/api/src/modules/featureX/featureX.service.ts", + "import { SomeType, Evaluation, helperB } from './featureX.engine';\n" + "export class FeatureXService {\n" + " evaluate(configs: SomeType[], lines: SomeType[]): Evaluation {\n" + " return helperB(configs, lines);\n" + " }\n" + "}\n"}, + {"packages/api/src/modules/featureX/featureX.service.spec.ts", + "import { SomeType, helperB } from './featureX.engine';\n" + "function featureX(overrides: Partial): SomeType {\n" + " return { id: 'x', qty: 1, ...overrides };\n" + "}\n" + "export function exerciseFixture(): number {\n" + " return helperB([featureX({})], [featureX({ qty: 2 })]).rateByItem.x;\n" + "}\n"}, + {"packages/api/src/modules/consumer/consumer.service.ts", + "import { helperB, type SomeType } from '../featureX/featureX.engine';\n" + "export class ConsumerService {\n" + " callerMethod(items: SomeType[]): number {\n" + " return helperB(items, [{ id: 'p1', qty: 1 }]).rateByItem.p1;\n" + " }\n" + "}\n"}, + {"packages/mobile/src/api.ts", + "export function helperB(token: string): Promise {\n" + " return fetch('/api/x', { method: 'POST', body: token });\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 consumer_id = ei_node_id_for_file_label(store, lp.project, consumer_path, "File"); + ASSERT_GT(consumer_id, 0); + + cbm_edge_t *edges = NULL; + int edge_count = 0; + ASSERT_EQ( + cbm_store_find_edges_by_source_type(store, consumer_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + + bool saw_helper = false; + bool saw_type = false; + bool helper_target_ok = false; + bool type_target_ok = false; + for (int i = 0; i < edge_count; i++) { + const char *props = edges[i].properties_json ? edges[i].properties_json : ""; + bool is_helper = strstr(props, "\"local_name\":\"helperB\"") != NULL; + bool is_type = strstr(props, "\"local_name\":\"SomeType\"") != NULL; + if (!is_helper && !is_type) { + 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); + bool target_ok = target->file_path && strcmp(target->file_path, engine_path) == 0; + if (is_helper) { + saw_helper = true; + helper_target_ok = target_ok; + } + if (is_type) { + saw_type = true; + type_target_ok = target_ok; + } + cbm_store_free_nodes(target, 1); + } + cbm_store_free_edges(edges, edge_count); + ei_cleanup(&lp, store); + + ASSERT_TRUE(saw_helper); + ASSERT_TRUE(saw_type); + ASSERT_TRUE(helper_target_ok); + ASSERT_TRUE(type_target_ok); + PASS(); +} + /* TypeScript: default import `import helper from './util'`. */ TEST(ei_typescript_default_import) { static const EILangFile f[] = { @@ -1021,6 +1111,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_default_import); RUN_TEST(ei_typescript_namespace_import); RUN_TEST(ei_typescript_aliased_import); diff --git a/tests/test_fqn.c b/tests/test_fqn.c index d2163424f..3b3f35f36 100644 --- a/tests/test_fqn.c +++ b/tests/test_fqn.c @@ -84,6 +84,21 @@ TEST(fqn_module_qn_still_strips_extension) { PASS(); } +TEST(fqn_relative_js_import_preserves_dotted_basename_issue1682) { + ASSERT_FQN( + cbm_pipeline_resolve_relative_import( + "packages/api/src/modules/consumer/consumer.service.ts", "../featureX/featureX.engine"), + "packages/api/src/modules/featureX/featureX.engine"); + ASSERT_FQN( + cbm_pipeline_resolve_relative_import("packages/api/src/modules/moduleA/moduleA.service.ts", + "../moduleQ/moduleQ.service"), + "packages/api/src/modules/moduleQ/moduleQ.service"); + ASSERT_FQN(cbm_pipeline_resolve_relative_import( + "packages/api/src/modules/moduleA/moduleA.service.ts", "./create-thing.dto"), + "packages/api/src/modules/moduleA/create-thing.dto"); + PASS(); +} + TEST(fqn_compute_basic_rs) { ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "lib.rs", "new"), "proj.lib.new"); PASS(); @@ -594,6 +609,7 @@ SUITE(fqn) { RUN_TEST(fqn_file_qn_preserves_dotfile_variants_issue1077); RUN_TEST(fqn_file_qn_distinguishes_same_stem_header_source_issue964); RUN_TEST(fqn_module_qn_still_strips_extension); + RUN_TEST(fqn_relative_js_import_preserves_dotted_basename_issue1682); RUN_TEST(fqn_compute_basic_rs); /* fqn_compute: nested paths */