Skip to content

Commit 3bbabf1

Browse files
authored
Merge pull request #1811 from pcristin/fix/issue-1682-dotted-ts-imports
fix(pipeline): preserve dotted TypeScript import basenames
2 parents 9ed01a4 + f47cd47 commit 3bbabf1

4 files changed

Lines changed: 124 additions & 14 deletions

File tree

‎src/pipeline/fqn.c‎

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -282,20 +282,19 @@ static char *resolve_python_relative(char *buf, size_t buf_size, const char *mod
282282
return strdup(buf);
283283
}
284284

285-
/* Strip a trailing file extension from a segment (e.g. "helpers.ts" → "helpers").
286-
* Returns the new segment length. */
287-
static size_t strip_ext(const char *seg_start, size_t seg_len) {
288-
const char *seg_end = seg_start + seg_len;
289-
const char *dot = NULL;
290-
for (const char *d = seg_end - FQN_SEP_LEN; d >= seg_start; d--) {
291-
if (*d == '.') {
292-
dot = d;
293-
break;
285+
/* Strip an explicit JS/TS module file extension while preserving dots that are
286+
* part of an extensionless basename (e.g. "featureX.engine"). */
287+
static size_t strip_js_module_ext(const char *seg_start, size_t seg_len) {
288+
static const char *const extensions[] = {
289+
".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".json",
290+
};
291+
for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); i++) {
292+
size_t ext_len = strlen(extensions[i]);
293+
if (seg_len > ext_len &&
294+
memcmp(seg_start + seg_len - ext_len, extensions[i], ext_len) == 0) {
295+
return seg_len - ext_len;
294296
}
295297
}
296-
if (dot && dot > seg_start) {
297-
return (size_t)(dot - seg_start);
298-
}
299298
return seg_len;
300299
}
301300

@@ -322,7 +321,7 @@ static char *resolve_js_relative(char *buf, size_t buf_size, const char *module_
322321
continue;
323322
}
324323
if (*p == '\0') {
325-
seg_len = strip_ext(seg_start, seg_len);
324+
seg_len = strip_js_module_ext(seg_start, seg_len);
326325
}
327326
if (seg_len > 0 && !path_append_segment(buf, buf_size, seg_start, seg_len)) {
328327
return NULL;

‎src/pipeline/pass_pkgmap.c‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1397,7 +1397,11 @@ char *cbm_pipeline_resolve_module(const cbm_pipeline_ctx_t *ctx, const char *sou
13971397
/* 1. Try relative import resolution (existing logic) */
13981398
char *resolved = cbm_pipeline_resolve_relative_import(source_rel, module_path);
13991399
if (resolved) {
1400-
char *qn = cbm_pipeline_fqn_module(ctx->project_name, resolved);
1400+
/* The relative resolver has already removed an explicit JS/TS file
1401+
* extension. Treat the remaining path as a module path verbatim so a
1402+
* dotted extensionless basename such as `featureX.engine` is not
1403+
* stripped a second time by cbm_pipeline_fqn_module. */
1404+
char *qn = cbm_pipeline_fqn_folder(ctx->project_name, resolved);
14011405
free(resolved);
14021406
return qn;
14031407
}

‎tests/test_edge_imports.c‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,96 @@ TEST(ei_typescript_named_relative_import) {
365365
PASS();
366366
}
367367

368+
/* #1682: extensionless dotted basenames are part of the module name. The
369+
* resolver used to strip `.engine`, miss the module, and bind both imports to
370+
* the same-named fixture Function in the sibling spec file. */
371+
TEST(ei_typescript_dotted_relative_import_targets_source_module_issue1682) {
372+
static const char *engine_path = "packages/api/src/modules/featureX/featureX.engine.ts";
373+
static const char *consumer_path = "packages/api/src/modules/consumer/consumer.service.ts";
374+
static const EILangFile f[] = {
375+
{"packages/api/src/modules/featureX/featureX.engine.ts",
376+
"export interface SomeType { id: string; qty: number; }\n"
377+
"export interface Evaluation { rateByItem: Record<string, number>; }\n"
378+
"export function helperB(configs: SomeType[], lines: SomeType[]): Evaluation {\n"
379+
" return { rateByItem: { [lines[0].id]: lines[0].qty + configs.length } };\n"
380+
"}\n"},
381+
{"packages/api/src/modules/featureX/featureX.service.ts",
382+
"import { SomeType, Evaluation, helperB } from './featureX.engine';\n"
383+
"export class FeatureXService {\n"
384+
" evaluate(configs: SomeType[], lines: SomeType[]): Evaluation {\n"
385+
" return helperB(configs, lines);\n"
386+
" }\n"
387+
"}\n"},
388+
{"packages/api/src/modules/featureX/featureX.service.spec.ts",
389+
"import { SomeType, helperB } from './featureX.engine';\n"
390+
"function featureX(overrides: Partial<SomeType>): SomeType {\n"
391+
" return { id: 'x', qty: 1, ...overrides };\n"
392+
"}\n"
393+
"export function exerciseFixture(): number {\n"
394+
" return helperB([featureX({})], [featureX({ qty: 2 })]).rateByItem.x;\n"
395+
"}\n"},
396+
{"packages/api/src/modules/consumer/consumer.service.ts",
397+
"import { helperB, type SomeType } from '../featureX/featureX.engine';\n"
398+
"export class ConsumerService {\n"
399+
" callerMethod(items: SomeType[]): number {\n"
400+
" return helperB(items, [{ id: 'p1', qty: 1 }]).rateByItem.p1;\n"
401+
" }\n"
402+
"}\n"},
403+
{"packages/mobile/src/api.ts",
404+
"export function helperB(token: string): Promise<unknown> {\n"
405+
" return fetch('/api/x', { method: 'POST', body: token });\n"
406+
"}\n"},
407+
};
408+
409+
EILangProj lp;
410+
cbm_store_t *store = ei_index_files(&lp, f, (int)(sizeof(f) / sizeof(f[0])));
411+
ASSERT_NOT_NULL(store);
412+
413+
int64_t consumer_id = ei_node_id_for_file_label(store, lp.project, consumer_path, "File");
414+
ASSERT_GT(consumer_id, 0);
415+
416+
cbm_edge_t *edges = NULL;
417+
int edge_count = 0;
418+
ASSERT_EQ(
419+
cbm_store_find_edges_by_source_type(store, consumer_id, "IMPORTS", &edges, &edge_count),
420+
CBM_STORE_OK);
421+
422+
bool saw_helper = false;
423+
bool saw_type = false;
424+
bool helper_target_ok = false;
425+
bool type_target_ok = false;
426+
for (int i = 0; i < edge_count; i++) {
427+
const char *props = edges[i].properties_json ? edges[i].properties_json : "";
428+
bool is_helper = strstr(props, "\"local_name\":\"helperB\"") != NULL;
429+
bool is_type = strstr(props, "\"local_name\":\"SomeType\"") != NULL;
430+
if (!is_helper && !is_type) {
431+
continue;
432+
}
433+
434+
cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t));
435+
ASSERT_NOT_NULL(target);
436+
ASSERT_EQ(cbm_store_find_node_by_id(store, edges[i].target_id, target), CBM_STORE_OK);
437+
bool target_ok = target->file_path && strcmp(target->file_path, engine_path) == 0;
438+
if (is_helper) {
439+
saw_helper = true;
440+
helper_target_ok = target_ok;
441+
}
442+
if (is_type) {
443+
saw_type = true;
444+
type_target_ok = target_ok;
445+
}
446+
cbm_store_free_nodes(target, 1);
447+
}
448+
cbm_store_free_edges(edges, edge_count);
449+
ei_cleanup(&lp, store);
450+
451+
ASSERT_TRUE(saw_helper);
452+
ASSERT_TRUE(saw_type);
453+
ASSERT_TRUE(helper_target_ok);
454+
ASSERT_TRUE(type_target_ok);
455+
PASS();
456+
}
457+
368458
/* TypeScript: default import `import helper from './util'`. */
369459
TEST(ei_typescript_default_import) {
370460
static const EILangFile f[] = {
@@ -1063,6 +1153,7 @@ SUITE(edge_imports) {
10631153

10641154
/* ── GREEN GUARDS — TypeScript (must stay passing) ── */
10651155
RUN_TEST(ei_typescript_named_relative_import);
1156+
RUN_TEST(ei_typescript_dotted_relative_import_targets_source_module_issue1682);
10661157
RUN_TEST(ei_typescript_default_import);
10671158
RUN_TEST(ei_typescript_namespace_import);
10681159
RUN_TEST(ei_typescript_aliased_import);

‎tests/test_fqn.c‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@ TEST(fqn_module_qn_still_strips_extension) {
8484
PASS();
8585
}
8686

87+
TEST(fqn_relative_js_import_preserves_dotted_basename_issue1682) {
88+
ASSERT_FQN(
89+
cbm_pipeline_resolve_relative_import(
90+
"packages/api/src/modules/consumer/consumer.service.ts", "../featureX/featureX.engine"),
91+
"packages/api/src/modules/featureX/featureX.engine");
92+
ASSERT_FQN(
93+
cbm_pipeline_resolve_relative_import("packages/api/src/modules/moduleA/moduleA.service.ts",
94+
"../moduleQ/moduleQ.service"),
95+
"packages/api/src/modules/moduleQ/moduleQ.service");
96+
ASSERT_FQN(cbm_pipeline_resolve_relative_import(
97+
"packages/api/src/modules/moduleA/moduleA.service.ts", "./create-thing.dto"),
98+
"packages/api/src/modules/moduleA/create-thing.dto");
99+
PASS();
100+
}
101+
87102
TEST(fqn_compute_basic_rs) {
88103
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "lib.rs", "new"), "proj.lib.new");
89104
PASS();
@@ -665,6 +680,7 @@ SUITE(fqn) {
665680
RUN_TEST(fqn_file_qn_preserves_dotfile_variants_issue1077);
666681
RUN_TEST(fqn_file_qn_distinguishes_same_stem_header_source_issue964);
667682
RUN_TEST(fqn_module_qn_still_strips_extension);
683+
RUN_TEST(fqn_relative_js_import_preserves_dotted_basename_issue1682);
668684
RUN_TEST(fqn_compute_basic_rs);
669685
RUN_TEST(fqn_compute_file_sibling_distinct);
670686
RUN_TEST(fqn_compute_symbol_still_strips);

0 commit comments

Comments
 (0)