Skip to content
25 changes: 12 additions & 13 deletions src/pipeline/fqn.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion src/pipeline/pass_pkgmap.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
91 changes: 91 additions & 0 deletions tests/test_edge_imports.c
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,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<string, number>; }\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>): 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<unknown> {\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[] = {
Expand Down Expand Up @@ -1063,6 +1153,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);
Expand Down
16 changes: 16 additions & 0 deletions tests/test_fqn.c
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -665,6 +680,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);
RUN_TEST(fqn_compute_file_sibling_distinct);
RUN_TEST(fqn_compute_symbol_still_strips);
Expand Down
Loading