diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index 0072aa999f..5955e35326 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -5317,6 +5317,54 @@ static const CBMType *interface_method_signature_from_ast(TSLSPContext *ctx, TSN return cbm_type_func(ctx->arena, NULL, param_count ? param_types : NULL, return_types); } +// `constructor(private readonly svc: Svc)` declares the field `svc`. A parameter +// is a property only when it carries an accessibility modifier, `readonly` or +// `override`; a plain parameter stays local to the constructor. +static int collect_parameter_properties(TSLSPContext *ctx, TSNode method, const char **names, + const CBMType **types, int count, int max_count) { + TSNode name = ts_node_child_by_field_name(method, "name", TS_LSP_FIELD_LEN("name")); + char *method_name = ts_node_is_null(name) ? NULL : node_text(ctx, name); + if (!method_name || strcmp(method_name, "constructor") != 0) + return count; + TSNode params = + ts_node_child_by_field_name(method, "parameters", TS_LSP_FIELD_LEN("parameters")); + if (ts_node_is_null(params)) + return count; + + uint32_t param_total = ts_node_named_child_count(params); + for (uint32_t i = 0; i < param_total && count < max_count; i++) { + TSNode param = ts_node_named_child(params, i); + const char *kind = ts_node_type(param); + if (strcmp(kind, "required_parameter") != 0 && strcmp(kind, "optional_parameter") != 0) + continue; + bool is_property = false; + uint32_t child_total = ts_node_child_count(param); + for (uint32_t j = 0; j < child_total && !is_property; j++) { + const char *ck = ts_node_type(ts_node_child(param, j)); + is_property = strcmp(ck, "accessibility_modifier") == 0 || + strcmp(ck, "override_modifier") == 0 || strcmp(ck, "readonly") == 0; + } + TSNode pattern = ts_node_child_by_field_name(param, "pattern", TS_LSP_FIELD_LEN("pattern")); + if (!is_property || ts_node_is_null(pattern) || + strcmp(ts_node_type(pattern), "identifier") != 0) + continue; + char *field_name = node_text(ctx, pattern); + if (!field_name) + continue; + const CBMType *field_type = cbm_type_unknown(); + TSNode annotation = ts_node_child_by_field_name(param, "type", TS_LSP_FIELD_LEN("type")); + if (!ts_node_is_null(annotation)) { + TSNode type_node = ts_node_named_child(annotation, 0); + if (!ts_node_is_null(type_node)) + field_type = ts_parse_type_node(ctx, type_node); + } + names[count] = field_name; + types[count] = field_type; + count++; + } + return count; +} + // AST sweep: walk class/interface bodies to collect field names+types and refine // embedded_types / type_param_names. static void ast_sweep_shapes(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg) { @@ -5447,6 +5495,12 @@ static void ast_sweep_shapes(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *re continue; } + if (!is_interface && strcmp(mk, "method_definition") == 0) { + field_count = + collect_parameter_properties(ctx, m, field_names, field_types, field_count, 63); + continue; + } + // Interface method signature: `methodName(params): ReturnType;` // Or class method declaration in `.d.ts` ambient mode. if (is_interface && (strcmp(mk, "method_signature") == 0)) { diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c index 9d0861155e..825089581e 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -282,11 +282,14 @@ static char *resolve_python_relative(char *buf, size_t buf_size, const char *mod return strdup(buf); } -/* 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) { +/* An explicit list, not the language table: the table also claims suffixes such + * as `.config` and `.d` that are ordinary parts of a dotted basename + * (`app.config`, `types.d`). Component files are imported with their + * extension spelled out, so they are listed beside the JS/TS ones. */ +size_t cbm_pipeline_strip_module_ext(const char *seg_start, size_t seg_len) { static const char *const extensions[] = { - ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".json", + ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", + ".cts", ".json", ".vue", ".svelte", ".astro", ".mdx", }; for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); i++) { size_t ext_len = strlen(extensions[i]); @@ -321,7 +324,7 @@ static char *resolve_js_relative(char *buf, size_t buf_size, const char *module_ continue; } if (*p == '\0') { - seg_len = strip_js_module_ext(seg_start, seg_len); + seg_len = cbm_pipeline_strip_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 bc534c5831..1fd6254484 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -1414,7 +1414,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); + /* As in step 1: the alias resolver already removed the file + * extension, so a dotted basename such as `x.service` must not + * be stripped again. */ + 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 8fae754c15..5c85eb6c05 100644 --- a/src/pipeline/path_alias.c +++ b/src/pipeline/path_alias.c @@ -47,21 +47,16 @@ enum { /* ── Helpers ───────────────────────────────────────────────────── */ -/* Strip .ts/.tsx/.js/.jsx in place. Returns its argument. */ +/* Strip an explicit module extension in place, keeping the dots of an + * extensionless basename such as `x.service`; the caller does not strip a + * second time. Returns its argument. */ static char *strip_resolved_ext(char *path) { if (!path) { return path; } - 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'; - } + char *last_slash = strrchr(path, '/'); + char *segment = last_slash ? last_slash + 1 : path; + segment[cbm_pipeline_strip_module_ext(segment, strlen(segment))] = '\0'; return path; } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index e5a672cb0b..275e3cd081 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -16,6 +16,7 @@ #define CBM_PIPELINE_H #include +#include #include #include @@ -201,6 +202,12 @@ char *cbm_pipeline_fqn_folder(const char *project, const char *rel_dir); * dot-only segments used by Python relative imports. */ char *cbm_pipeline_resolve_relative_import(const char *source_rel, const char *module_path); +/* Length of the last path segment [seg_start, seg_start + seg_len) without an + * explicit module file extension (`.ts`, `.vue`, ...). Dots that belong to an + * extensionless basename such as `x.service` are kept. Every import resolver + * strips through this one list so their module QNs agree. */ +size_t cbm_pipeline_strip_module_ext(const char *seg_start, size_t seg_len); + /* Derive project name from an absolute path. * Replaces / and : with -, collapses --, trims leading -. * Caller must free() the returned string. */ diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 51b1423440..0840fafe37 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -455,6 +455,68 @@ TEST(ei_typescript_dotted_relative_import_targets_source_module_issue1682) { PASS(); } +/* The #1682 dotted-basename fix, through a tsconfig `paths` alias. The alias + * branch stripped `.service` from `src/conversations/conversations.service`, + * missed the module, and bound the import to an unrelated file that also + * mentions the class (the NestJS `x.service.ts` layout hits this on every + * aliased import). */ +TEST(ei_typescript_dotted_alias_import_targets_source_module) { + static const char *service_path = "src/conversations/conversations.service.ts"; + static const char *consumer_path = "src/scrm/customers/scrm-interaction.service.ts"; + static const EILangFile f[] = { + {"tsconfig.json", + "{ \"compilerOptions\": { \"baseUrl\": \"./\", \"paths\": { \"src/*\": [\"src/*\"] } } }\n"}, + {"src/conversations/conversations.service.ts", + "export class ConversationsService {\n" + " addTagsToConversation(dto: string): string { return dto; }\n" + "}\n"}, + {"src/tasks/capabilities/task-write-capabilities.spec.ts", + "import { ConversationsService } from 'src/conversations/conversations.service';\n" + "export function ConversationsServiceFixture(): ConversationsService {\n" + " return new ConversationsService();\n" + "}\n"}, + {"src/scrm/customers/scrm-interaction.service.ts", + "import { ConversationsService } from 'src/conversations/conversations.service';\n" + "export class ScrmInteractionService {\n" + " run(s: ConversationsService): string { return s.addTagsToConversation('x'); }\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_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\":\"ConversationsService\"")) { + 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[] = { @@ -1154,6 +1216,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_fqn.c b/tests/test_fqn.c index f9d17fa0c3..3cfb9d5d68 100644 --- a/tests/test_fqn.c +++ b/tests/test_fqn.c @@ -96,6 +96,10 @@ TEST(fqn_relative_js_import_preserves_dotted_basename_issue1682) { 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"); + /* A spelled-out component extension is stripped, matching the Vue file's + * module QN. */ + ASSERT_FQN(cbm_pipeline_resolve_relative_import("web/src/App.ts", "./components/Foo.vue"), + "web/src/components/Foo"); PASS(); } diff --git a/tests/test_path_alias.c b/tests/test_path_alias.c index a0bdc89364..841f8dfdb8 100644 --- a/tests/test_path_alias.c +++ b/tests/test_path_alias.c @@ -154,6 +154,26 @@ TEST(path_alias_strips_ext) { PASS(); } +/* The resolved path becomes a module QN with no further stripping, so a spelled + * out component extension must go while a dotted basename stays. */ +TEST(path_alias_strips_component_ext_keeps_dotted_basename) { + cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*"); + char *vue = cbm_path_alias_resolve(m, "@/components/Foo.vue"); + char *service = cbm_path_alias_resolve(m, "@/conversations/conversations.service"); + char *config = cbm_path_alias_resolve(m, "@/config/app.config"); + ASSERT_NOT_NULL(vue); + ASSERT_NOT_NULL(service); + ASSERT_NOT_NULL(config); + ASSERT_STR_EQ(vue, "src/components/Foo"); + ASSERT_STR_EQ(service, "src/conversations/conversations.service"); + ASSERT_STR_EQ(config, "src/config/app.config"); + free(vue); + free(service); + free(config); + free_map(m); + PASS(); +} + /* ── baseUrl fallback for non-relative, non-package imports ────── */ TEST(path_alias_baseurl_fallback) { @@ -414,6 +434,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_component_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 b5e38b5922..920fa1c89c 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -4125,6 +4125,23 @@ TEST(tslsp_void_returning_method) { PASS(); } +/* Constructor parameter properties are fields: NestJS injects every service as + * `constructor(private readonly svc: Svc)` and calls it as `this.svc.m()`. */ +TEST(tslsp_constructor_parameter_property_resolution) { + CBMFileResult *r = extract_ts("class Svc { work(): void {} audit(): void {} }\n" + "class Logger { log(): void {} }\n" + "class Controller {\n" + " constructor(private readonly svc: Svc, public logger: Logger,\n" + " plain: Svc) {}\n" + " handle(): void { this.svc.work(); this.logger.log(); }\n" + "}\n"); + ASSERT_NOT_NULL(r); + ASSERT_GTE(require_resolved(r, ".handle", "Svc.work"), 0); + ASSERT_GTE(require_resolved(r, ".handle", "Logger.log"), 0); + cbm_free_result(r); + PASS(); +} + TEST(tslsp_nested_class_resolution) { CBMFileResult *r = extract_ts("class Outer {\n" " inner: Inner = new Inner();\n" @@ -4695,6 +4712,7 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_in_operator_narrow_smoke); RUN_TEST(tslsp_optional_property_chain); RUN_TEST(tslsp_void_returning_method); + RUN_TEST(tslsp_constructor_parameter_property_resolution); RUN_TEST(tslsp_nested_class_resolution); /* Expression-eval complexity guard. */