From fd9f1d83025e569039314436a6f780f284a69d99 Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Wed, 21 Oct 2026 07:42:06 +0000 Subject: [PATCH 1/2] fix(ts-lsp): resolve calls through renamed named imports `import { foo as fooShared } from "./lib"; fooShared(1)` produced no CALLS edge to lib.foo, so trace_path(foo, inbound) missed the caller while plain named and namespace imports resolved. The TS LSP import map holds only the local binding, and every import-binding lookup spelled `.` (lib.fooShared), which names nothing. Read the file's own `import { a as b }` specifiers into a local -> exported-name map in all three LSP entry points (per-file and both cross-file paths), before the type passes, and look up the exported member wherever an import binding becomes a member QN: free calls, function values, identifier and `new` types, imported type names and JSX components. The lookup stays exact: a renamed import of a name the module does not export resolves to nothing instead of to a same-named export that matches the alias. `default as x` keeps its local-name lookup. The pipeline joins an LSP row to its call by the callee's leaf name, which for an alias is not the name written at the site. Calls and JSX tags through a renamed import are emitted as lsp_import_alias and function values as lsp_callable_value_reference, with the written name in `reason`, the reason-join strategies the Python, Go, Kotlin, C# and C resolvers already use for aliases. Fixes #2297 Signed-off-by: Gaurav Dubey --- internal/cbm/lsp/ts_lsp.c | 143 +++++++++++++++++++++++++++++++++----- internal/cbm/lsp/ts_lsp.h | 7 ++ tests/test_pipeline.c | 76 ++++++++++++++++++++ tests/test_ts_lsp.c | 129 ++++++++++++++++++++++++++++++++++ 4 files changed, 339 insertions(+), 16 deletions(-) diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index 8ee5e6642..27cf50ff2 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -261,8 +261,12 @@ static TSNode find_first_kind(TSNode node, const char *kind) { return null_node; } -static void ts_emit_resolved_call_at(TSLSPContext *ctx, const char *callee_qn, const char *strategy, - float confidence, TSNode site) { +/* `reason` carries the name written at the site when it differs from the + * callee's leaf (`bar()` through `import { foo as bar }`): the pipeline joins an + * alias strategy by that name instead of by the callee QN's last segment. */ +static void ts_emit_resolved_call_reason_at(TSLSPContext *ctx, const char *callee_qn, + const char *strategy, float confidence, + const char *reason, TSNode site) { if (!ctx || !ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn) return; CBMResolvedCall rc = {0}; @@ -270,7 +274,7 @@ static void ts_emit_resolved_call_at(TSLSPContext *ctx, const char *callee_qn, c rc.callee_qn = callee_qn; rc.strategy = strategy ? strategy : "lsp_ts"; rc.confidence = confidence; - rc.reason = NULL; + rc.reason = reason; rc.kind = CBM_RESOLVED_INVOCATION; if (!ts_node_is_null(site)) { rc.site_start_byte = ts_node_start_byte(site); @@ -279,8 +283,14 @@ static void ts_emit_resolved_call_at(TSLSPContext *ctx, const char *callee_qn, c cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc); } -static void ts_emit_resolved_reference(TSLSPContext *ctx, const char *callee_qn, - const char *strategy, float confidence, TSNode site) { +static void ts_emit_resolved_call_at(TSLSPContext *ctx, const char *callee_qn, const char *strategy, + float confidence, TSNode site) { + ts_emit_resolved_call_reason_at(ctx, callee_qn, strategy, confidence, NULL, site); +} + +static void ts_emit_resolved_reference_reason(TSLSPContext *ctx, const char *callee_qn, + const char *strategy, float confidence, + const char *reason, TSNode site) { if (!ctx || !ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn || ts_node_is_null(site)) { return; @@ -290,13 +300,18 @@ static void ts_emit_resolved_reference(TSLSPContext *ctx, const char *callee_qn, rc.callee_qn = callee_qn; rc.strategy = strategy ? strategy : "lsp_ts_value_reference"; rc.confidence = confidence; - rc.reason = NULL; + rc.reason = reason; rc.kind = CBM_RESOLVED_CALL_REFERENCE; rc.site_start_byte = ts_node_start_byte(site); rc.site_end_byte = ts_node_end_byte(site); cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc); } +static void ts_emit_resolved_reference(TSLSPContext *ctx, const char *callee_qn, + const char *strategy, float confidence, TSNode site) { + ts_emit_resolved_reference_reason(ctx, callee_qn, strategy, confidence, NULL, site); +} + static void ts_emit_unresolved_call_at(TSLSPContext *ctx, const char *expr_text, const char *reason, TSNode site) { if (!ctx || !ctx->resolved_calls || !ctx->enclosing_func_qn) @@ -1417,6 +1432,19 @@ static bool ts_scope_has_binding(const CBMScope *scope, const char *name) { return ts_scope_binding_owner(scope, name) != NULL; } +/* The member an import binding names in its module: the exported name of a + * renamed named import (`foo` for `import { foo as bar }`, #2297), else the + * binding itself. Callers have already matched `local` to an import binding. */ +static const char *ts_import_export_name(const TSLSPContext *ctx, const char *local) { + if (!ctx || !local) + return local; + for (int i = 0; i < ctx->import_alias_count; i++) { + if (strcmp(ctx->import_alias_locals[i], local) == 0) + return ctx->import_alias_exports[i]; + } + return local; +} + /* Full QN of an imported symbol. An import value is normally the module QN * (symbol = module + "." + name), but may already be the symbol QN. When the * module QN ends in ".name" the spelling is ambiguous: `Foo.ts` exporting @@ -1455,8 +1483,9 @@ static const CBMRegisteredFunc *ts_exact_same_module_function(TSLSPContext *ctx, } /* Return a function only when the local import binding maps to one unambiguous - * module and that module contains the exact local-name QN. Aliases whose export - * name is unavailable in CBMImport deliberately fail closed and remain USAGE. */ + * module and that module contains the exact QN of the member the binding names + * (the exported name for `import { foo as bar }`). A renamed import of a name + * the module does not export fails closed and remains USAGE. */ static const CBMRegisteredFunc *ts_exact_imported_function(TSLSPContext *ctx, const char *name) { if (!ctx || !ctx->registry || !name || ts_scope_has_binding(ctx->current_scope, name)) { return NULL; @@ -1476,7 +1505,8 @@ static const CBMRegisteredFunc *ts_exact_imported_function(TSLSPContext *ctx, co if (!matched_module) { return NULL; } - const char *full_qn = ts_import_symbol_qn(ctx, matched_module, name); + const char *full_qn = + ts_import_symbol_qn(ctx, matched_module, ts_import_export_name(ctx, name)); return full_qn ? cbm_registry_lookup_func(ctx->registry, full_qn) : NULL; } @@ -1524,7 +1554,7 @@ static const CBMType *type_of_identifier(TSLSPContext *ctx, const char *name) { // Construct the full symbol QN using the same convention as exact // imported function-value resolution. - const char *full_qn = ts_import_symbol_qn(ctx, mqn, name); + const char *full_qn = ts_import_symbol_qn(ctx, mqn, ts_import_export_name(ctx, name)); // Try the symbol lookup precedence: registered func, then registered type. const CBMRegisteredFunc *f = cbm_registry_lookup_func(ctx->registry, full_qn); @@ -2079,7 +2109,8 @@ static const CBMType *ts_new_bare_class_type(TSLSPContext *ctx, const char *cnam bool import_matched = false; const char *import_module = ts_import_module_for_local(ctx, cname, &import_matched); if (import_module) - return cbm_type_named(ctx->arena, ts_import_symbol_qn(ctx, import_module, cname)); + return cbm_type_named( + ctx->arena, ts_import_symbol_qn(ctx, import_module, ts_import_export_name(ctx, cname))); return cbm_type_named(ctx->arena, local_qn); } @@ -2558,7 +2589,8 @@ static const CBMType *resolve_type_with_imports(TSLSPContext *ctx, const CBMType continue; if (strcmp(lname, bare) != 0) continue; - return cbm_type_named(ctx->arena, ts_import_symbol_qn(ctx, mqn, bare)); + return cbm_type_named(ctx->arena, + ts_import_symbol_qn(ctx, mqn, ts_import_export_name(ctx, bare))); } return t; } @@ -2925,12 +2957,19 @@ static void resolve_value_references_at(TSLSPContext *ctx, TSNode call_node) { } const CBMRegisteredFunc *target = ts_exact_same_module_function(ctx, name); const char *strategy = "lsp_ts_same_module_value_reference"; + const char *reason = NULL; if (!target) { target = ts_exact_imported_function(ctx, name); strategy = "lsp_ts_import_value_reference"; + /* `import { foo as bar }`: the site names `bar`, the target is `foo`. */ + if (target && ts_import_export_name(ctx, name) != name) { + strategy = "lsp_callable_value_reference"; + reason = name; + } } if (target) { - ts_emit_resolved_reference(ctx, target->qualified_name, strategy, 0.95f, arg); + ts_emit_resolved_reference_reason(ctx, target->qualified_name, strategy, 0.95f, reason, + arg); } } } @@ -3017,14 +3056,20 @@ static void resolve_call_at(TSLSPContext *ctx, TSNode call_node) { const char *lname = ctx->import_local_names ? ctx->import_local_names[i] : NULL; const char *mqn = ctx->import_module_qns ? ctx->import_module_qns[i] : NULL; if (lname && mqn && strcmp(lname, name) == 0) { - const CBMRegisteredFunc *f = ts_lookup_symbol_for_call(ctx, mqn, name, args); + const char *member = ts_import_export_name(ctx, name); + const CBMRegisteredFunc *f = ts_lookup_symbol_for_call(ctx, mqn, member, args); + if (f && member != name) { + ts_emit_resolved_call_reason_at(ctx, f->qualified_name, "lsp_import_alias", + 0.95f, name, call_node); + return; + } if (f) { ts_emit_resolved_call_at(ctx, f->qualified_name, "lsp_ts_import", 0.95f, call_node); return; } // Fall through to qualified_name fallback. - const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", mqn, name); + const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", mqn, member); ts_emit_unresolved_call_at(ctx, qn, "import_symbol_not_in_registry", call_node); return; } @@ -3366,7 +3411,13 @@ static void resolve_jsx_element(TSLSPContext *ctx, TSNode element_node) { * per-file row deliberately keeps the file eligible for the * cross-file pass, where resolved import QNs and project defs are * available. */ - const CBMRegisteredFunc *f = cbm_registry_lookup_symbol(ctx->registry, mqn, tag_name); + const char *member = ts_import_export_name(ctx, tag_name); + const CBMRegisteredFunc *f = cbm_registry_lookup_symbol(ctx->registry, mqn, member); + if (f && member != tag_name) { + ts_emit_resolved_call_reason_at(ctx, f->qualified_name, "lsp_import_alias", 0.95f, + tag_name, tag); + return; + } if (f) { ts_emit_resolved_call_at(ctx, f->qualified_name, "lsp_ts_jsx_import", 0.95f, tag); return; @@ -3996,6 +4047,63 @@ void ts_lsp_add_import(TSLSPContext *ctx, const char *local_name, const char *mo ctx->import_count = new_count; } +static void ts_lsp_add_import_alias(TSLSPContext *ctx, const char *local, const char *exported) { + int new_count = ctx->import_alias_count + 1; + const char **locals = + (const char **)cbm_arena_alloc(ctx->arena, (size_t)new_count * sizeof(const char *)); + const char **exports = + (const char **)cbm_arena_alloc(ctx->arena, (size_t)new_count * sizeof(const char *)); + if (!locals || !exports) + return; + for (int i = 0; i < ctx->import_alias_count; i++) { + locals[i] = ctx->import_alias_locals[i]; + exports[i] = ctx->import_alias_exports[i]; + } + locals[ctx->import_alias_count] = local; + exports[ctx->import_alias_count] = exported; + ctx->import_alias_locals = locals; + ctx->import_alias_exports = exports; + ctx->import_alias_count = new_count; +} + +/* Record each `import { foo as bar }` binding of the file (#2297). The import + * map holds only local bindings, so a renamed binding spelled `.bar`, + * named nothing, and every call through it lost its CALLS edge. Reading the + * file's own import statements serves the per-file and both cross-file entry + * points alike. `default as x` keeps today's local-name lookup (a default + * import binds no exported member name); string export names and identity + * aliases need no entry. Must run before the type passes, which resolve + * imported type names. */ +static void ts_lsp_collect_import_aliases(TSLSPContext *ctx, TSNode root) { + if (!ctx || !ctx->arena || ts_node_is_null(root) || ctx->import_count == 0) + return; + uint32_t kn = 0; + TSNode *kids = collect_children(ctx->arena, root, &kn); + for (uint32_t i = 0; i < kn; i++) { + if (!node_kind_is(kids[i], "import_statement")) + continue; + TSNode named = find_first_kind(find_first_kind(kids[i], "import_clause"), "named_imports"); + if (ts_node_is_null(named)) + continue; + uint32_t sn = 0; + TSNode *specs = collect_children(ctx->arena, named, &sn); + for (uint32_t j = 0; j < sn; j++) { + if (!node_kind_is(specs[j], "import_specifier")) + continue; + TSNode name = ts_node_child_by_field_name(specs[j], "name", TS_LSP_FIELD_LEN("name")); + TSNode alias = + ts_node_child_by_field_name(specs[j], "alias", TS_LSP_FIELD_LEN("alias")); + if (!node_kind_is(name, "identifier") || !node_kind_is(alias, "identifier")) + continue; + char *exported = node_text(ctx, name); + char *local = node_text(ctx, alias); + if (exported && local && strcmp(exported, "default") != 0 && + strcmp(exported, local) != 0) + ts_lsp_add_import_alias(ctx, local, exported); + } + } +} + // ── Stdlib seeds (Phase 5 will replace with generator) ──────────────────────── static const CBMType *tt_builtin(CBMArena *a, const char *n) { @@ -5787,6 +5895,7 @@ void cbm_run_ts_lsp(CBMArena *arena, CBMFileResult *result, const char *source, ts_lsp_add_import(&ctx, imp->local_name, imp->module_path); } } + ts_lsp_collect_import_aliases(&ctx, root); ast_sweep_shapes(&ctx, root, ®); rebuild_signatures_from_ast(&ctx, root, ®); @@ -6041,6 +6150,7 @@ void cbm_run_ts_lsp_cross_with_registry(CBMArena *arena, const char *source, int ts_lsp_add_import(&ctx, import_names[i], import_qns[i]); } } + ts_lsp_collect_import_aliases(&ctx, root); ast_sweep_shapes(&ctx, root, &overlay); rebuild_signatures_from_ast(&ctx, root, &overlay); @@ -6257,6 +6367,7 @@ void cbm_run_ts_lsp_cross(CBMArena *arena, const char *source, int source_len, ts_lsp_add_import(&ctx, import_names[i], import_qns[i]); } } + ts_lsp_collect_import_aliases(&ctx, root); ast_sweep_shapes(&ctx, root, ®); rebuild_signatures_from_ast(&ctx, root, ®); diff --git a/internal/cbm/lsp/ts_lsp.h b/internal/cbm/lsp/ts_lsp.h index 1096bea90..312ae985e 100644 --- a/internal/cbm/lsp/ts_lsp.h +++ b/internal/cbm/lsp/ts_lsp.h @@ -33,6 +33,13 @@ typedef struct { const char **import_module_qns; int import_count; + // Renamed named imports (`import { foo as bar }`): local binding -> the name + // the module exports. Parallel arrays of length import_alias_count; a + // binding absent here is exported under its local name. + const char **import_alias_locals; + const char **import_alias_exports; + int import_alias_count; + // File / surrounding context. const char *module_qn; // QN of this file's module const char *enclosing_func_qn; // current function being walked, NULL at module scope diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index bd76775f3..2a5923236 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -9282,6 +9282,81 @@ TEST(pipeline_ts_crossfile_new_instance_method_call_issue1354) { PASS(); } +/* Issue #2297: a call through `import { foo as fooShared }` produced no CALLS + * edge, so trace_path(foo, inbound) missed the caller. End to end through the + * real pipeline: the renamed call, an app-local wrapper that shares the + * exported name, and a method on a renamed class import all reach the + * exported member, while the plain named import control keeps lsp_ts_import. */ +TEST(pipeline_ts_renamed_import_call_issue2297) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_ts_2297_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/lib.ts", + "export function fooUniqueQrs(x: number): number {\n" + " return x + 1;\n" + "}\n" + "export function buildHealthUniqueQrs(input: { a: number }): number {\n" + " return input.a;\n" + "}\n" + "export class ConnUniqueQrs {\n" + " pingUniqueQrs(): number {\n" + " return 1;\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/plain.ts", + "import { fooUniqueQrs } from './lib';\n" + "export function callsPlain(): number {\n" + " return fooUniqueQrs(1);\n" + "}\n"); + write_temp_file(tmp, "src/renamed.ts", + "import { fooUniqueQrs as fooShared } from './lib';\n" + "export function callsRenamed(): number {\n" + " return fooShared(1);\n" + "}\n"); + write_temp_file(tmp, "src/wrapper.ts", + "import { buildHealthUniqueQrs as buildShared } from './lib';\n" + "export function buildHealthUniqueQrs(input: { a: number }): number {\n" + " return buildShared({ ...input });\n" + "}\n"); + write_temp_file(tmp, "src/klass.ts", + "import { ConnUniqueQrs as Connection } from './lib';\n" + "export function viaRenamedClass(): number {\n" + " return new Connection().pingUniqueQrs();\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/ts_2297.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + bool plain = + cross_file_call_has_strategy(s, project, "callsPlain", "fooUniqueQrs", "lsp_ts_import"); + bool renamed = cross_file_call_has_strategy(s, project, "callsRenamed", "fooUniqueQrs", + "lsp_import_alias"); + bool wrapper = cross_file_call_has_strategy(s, project, "buildHealthUniqueQrs", + "buildHealthUniqueQrs", "lsp_import_alias"); + bool renamed_class = cross_file_call_has_strategy(s, project, "viaRenamedClass", + "pingUniqueQrs", "lsp_ts_method"); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + + ASSERT_TRUE(plain); + ASSERT_TRUE(renamed); + ASSERT_TRUE(wrapper); + ASSERT_TRUE(renamed_class); + PASS(); +} + /* A query failure is distinct from a valid zero-edge result. */ static int ts514_pipeline_count(cbm_store_t *store, const char *sql, const char **values, int value_count) { @@ -27677,6 +27752,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_axios_wrapper_baseurl_composes_http_calls_issue1916); RUN_TEST(pipeline_ts_crossfile_new_instance_method_call_issue1354); + RUN_TEST(pipeline_ts_renamed_import_call_issue2297); RUN_TEST(pipeline_ts_param_property_injection_issue514); RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_php_receiver_suppresses_weak_method_edge); diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index d4ed45c0c..a5531452d 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -2799,6 +2799,132 @@ TEST(tslsp_crossfile_no_def_match) { PASS(); } +/* Issue #2297: `import { foo as fooShared }` bound only the local alias, and + * every import lookup spelled `.`, which names nothing, so a call + * through a renamed import produced no CALLS edge. The binding resolves to the + * name the module exports, exactly like a plain named import. Driven through + * the production Tier-2 path (shared sealed registry + per-file overlay). */ +TEST(tslsp_crossfile_renamed_import_issue2297) { + CBMLSPDef defs[] = { + {.qualified_name = "test.lib.foo", + .short_name = "foo", + .label = "Function", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.lib", + .return_types = "number"}, + {.qualified_name = "test.conn.Conn", + .short_name = "Conn", + .label = "Class", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.conn", + .method_names_str = "ping"}, + {.qualified_name = "test.conn.Conn.ping", + .short_name = "ping", + .label = "Method", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.conn", + .receiver_type = "test.conn.Conn"}, + {.qualified_name = "test.main.callsRenamed", + .short_name = "callsRenamed", + .label = "Function", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.main"}, + {.qualified_name = "test.main.viaRenamedClass", + .short_name = "viaRenamedClass", + .label = "Function", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.main"}, + }; + enum { NDEFS = (int)(sizeof(defs) / sizeof(defs[0])) }; + const char *imp_names[] = {"fooShared", "Connection"}; + const char *imp_qns[] = {"test.lib", "test.conn"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *reg = cbm_ts_build_cross_registry(&arena, defs, NDEFS); + ASSERT_NOT_NULL(reg); + + const char *src = "import { foo as fooShared } from './lib';\n" + "import { Conn as Connection } from './conn';\n" + "export function callsRenamed() { return fooShared(1); }\n" + "export function viaRenamedClass() { const c = new Connection(); c.ping(); }\n"; + CBMResolvedCallArray out = {0}; + cbm_run_ts_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.main", false, false, + false, reg, defs, NDEFS, imp_names, imp_qns, 2, NULL, &out); + /* The call site names the alias, so the join reads it from `reason`. */ + ASSERT_TRUE( + has_exact_resolved(&out, "test.main.callsRenamed", "test.lib.foo", "lsp_import_alias")); + ASSERT_TRUE(has_exact_resolved(&out, "test.main.viaRenamedClass", "test.conn.Conn.ping", + "lsp_ts_method")); + + cbm_arena_destroy(&arena); + PASS(); +} + +/* The export name is looked up exactly. A renamed import of a name the module + * does not export must not fall back to the alias: before #2297 the alias `foo` + * spelled `test.lib.foo` and bound this call to an unrelated export. */ +TEST(tslsp_crossfile_renamed_import_missing_export_issue2297) { + const char *source = "import { missing as foo } from './lib';\n" + "function go() { foo(1); }\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.main.go", + .short_name = "go", + .label = "Function", + .def_module_qn = "test.main"}, + {.qualified_name = "test.lib.foo", + .short_name = "foo", + .label = "Function", + .def_module_qn = "test.lib"}, + }; + const char *imp_names[] = {"foo"}; + const char *imp_qns[] = {"test.lib"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + + cbm_run_ts_lsp_cross(&arena, source, (int)strlen(source), "test.main", false, false, false, + defs, 2, imp_names, imp_qns, 1, NULL, &out); + + ASSERT_EQ(find_resolved_arr(&out, "go", "test.lib.foo"), -1); + + cbm_arena_destroy(&arena); + PASS(); +} + +/* `import { default as greet }` is a default import: it keeps resolving by its + * local name, as `import greet from` does. */ +TEST(tslsp_crossfile_default_as_import_issue2297) { + const char *source = "import { default as greet } from './greetings';\n" + "function go() { greet('hi'); }\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.main.go", + .short_name = "go", + .label = "Function", + .def_module_qn = "test.main"}, + {.qualified_name = "test.greetings.greet", + .short_name = "greet", + .label = "Function", + .def_module_qn = "test.greetings", + .return_types = "string"}, + }; + const char *imp_names[] = {"greet"}; + const char *imp_qns[] = {"test.greetings"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + + cbm_run_ts_lsp_cross(&arena, source, (int)strlen(source), "test.main", false, false, false, + defs, 2, imp_names, imp_qns, 1, NULL, &out); + + ASSERT_GTE(find_resolved_arr(&out, "go", "test.greetings.greet"), 0); + + cbm_arena_destroy(&arena); + PASS(); +} + /* ── More stdlib (Date, RegExp, Error, Math) ──────────────────────────────── */ TEST(tslsp_stdlib_date_toISOString) { @@ -5111,6 +5237,9 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_crossfile_function_call); RUN_TEST(tslsp_crossfile_chain_through_return); RUN_TEST(tslsp_crossfile_no_def_match); + RUN_TEST(tslsp_crossfile_renamed_import_issue2297); + RUN_TEST(tslsp_crossfile_renamed_import_missing_export_issue2297); + RUN_TEST(tslsp_crossfile_default_as_import_issue2297); /* More stdlib */ RUN_TEST(tslsp_stdlib_date_toISOString); From 2a9796e0167bf3f5a0a9add55458a626d95949b1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 22:09:32 +0200 Subject: [PATCH 2/2] fix(lsp): preserve overloads through imported TypeScript aliases Signed-off-by: Martin Vogel --- internal/cbm/lsp/ts_lsp.c | 38 +++++++----- internal/cbm/lsp/ts_lsp.h | 1 + tests/test_pipeline.c | 27 ++++++++- tests/test_ts_lsp.c | 122 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 18 deletions(-) diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index 27cf50ff2..25961d06f 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -2231,7 +2231,8 @@ static const CBMType *ts_signature_for_call(TSLSPContext *ctx, TSNode function, if (!registered) { const char *module_qn = ts_import_module_for_local(ctx, name, NULL); if (module_qn) - registered = ts_lookup_symbol_for_call(ctx, module_qn, name, args); + registered = ts_lookup_symbol_for_call(ctx, module_qn, + ts_import_export_name(ctx, name), args); } if (registered) return registered->signature; @@ -4048,22 +4049,27 @@ void ts_lsp_add_import(TSLSPContext *ctx, const char *local_name, const char *mo } static void ts_lsp_add_import_alias(TSLSPContext *ctx, const char *local, const char *exported) { - int new_count = ctx->import_alias_count + 1; - const char **locals = - (const char **)cbm_arena_alloc(ctx->arena, (size_t)new_count * sizeof(const char *)); - const char **exports = - (const char **)cbm_arena_alloc(ctx->arena, (size_t)new_count * sizeof(const char *)); - if (!locals || !exports) - return; - for (int i = 0; i < ctx->import_alias_count; i++) { - locals[i] = ctx->import_alias_locals[i]; - exports[i] = ctx->import_alias_exports[i]; + if ((size_t)ctx->import_alias_count == ctx->import_alias_capacity) { + /* The arena retains old arrays: grow geometrically so collecting A + * aliases retains O(A) pointers instead of O(A squared). */ + size_t capacity = ctx->import_alias_capacity ? ctx->import_alias_capacity * 2 : 8; + const char **locals = + (const char **)cbm_arena_alloc(ctx->arena, capacity * sizeof(const char *)); + const char **exports = + (const char **)cbm_arena_alloc(ctx->arena, capacity * sizeof(const char *)); + if (!locals || !exports) + return; + for (int i = 0; i < ctx->import_alias_count; i++) { + locals[i] = ctx->import_alias_locals[i]; + exports[i] = ctx->import_alias_exports[i]; + } + ctx->import_alias_locals = locals; + ctx->import_alias_exports = exports; + ctx->import_alias_capacity = capacity; } - locals[ctx->import_alias_count] = local; - exports[ctx->import_alias_count] = exported; - ctx->import_alias_locals = locals; - ctx->import_alias_exports = exports; - ctx->import_alias_count = new_count; + ctx->import_alias_locals[ctx->import_alias_count] = local; + ctx->import_alias_exports[ctx->import_alias_count] = exported; + ctx->import_alias_count++; } /* Record each `import { foo as bar }` binding of the file (#2297). The import diff --git a/internal/cbm/lsp/ts_lsp.h b/internal/cbm/lsp/ts_lsp.h index 312ae985e..fab7fa578 100644 --- a/internal/cbm/lsp/ts_lsp.h +++ b/internal/cbm/lsp/ts_lsp.h @@ -39,6 +39,7 @@ typedef struct { const char **import_alias_locals; const char **import_alias_exports; int import_alias_count; + size_t import_alias_capacity; // File / surrounding context. const char *module_qn; // QN of this file's module diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2a5923236..043c38eaa 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -9341,8 +9341,30 @@ TEST(pipeline_ts_renamed_import_call_issue2297) { cross_file_call_has_strategy(s, project, "callsPlain", "fooUniqueQrs", "lsp_ts_import"); bool renamed = cross_file_call_has_strategy(s, project, "callsRenamed", "fooUniqueQrs", "lsp_import_alias"); - bool wrapper = cross_file_call_has_strategy(s, project, "buildHealthUniqueQrs", - "buildHealthUniqueQrs", "lsp_import_alias"); + char wrapper_qn[512]; + char exported_qn[512]; + snprintf(wrapper_qn, sizeof(wrapper_qn), "%s.src.wrapper.buildHealthUniqueQrs", project); + snprintf(exported_qn, sizeof(exported_qn), "%s.src.lib.buildHealthUniqueQrs", project); + cbm_node_t wrapper_node = {0}; + cbm_node_t exported_node = {0}; + bool wrapper = false; + bool recursive = false; + if (cbm_store_find_node_by_qn(s, project, wrapper_qn, &wrapper_node) == CBM_STORE_OK && + cbm_store_find_node_by_qn(s, project, exported_qn, &exported_node) == CBM_STORE_OK) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_source_type(s, wrapper_node.id, "CALLS", &edges, &edge_count); + for (int i = 0; i < edge_count; i++) { + if (edges[i].target_id == exported_node.id && edges[i].properties_json && + strstr(edges[i].properties_json, "lsp_import_alias")) + wrapper = true; + if (edges[i].target_id == wrapper_node.id) + recursive = true; + } + cbm_store_free_edges(edges, edge_count); + } + cbm_node_free_fields(&wrapper_node); + cbm_node_free_fields(&exported_node); bool renamed_class = cross_file_call_has_strategy(s, project, "viaRenamedClass", "pingUniqueQrs", "lsp_ts_method"); @@ -9353,6 +9375,7 @@ TEST(pipeline_ts_renamed_import_call_issue2297) { ASSERT_TRUE(plain); ASSERT_TRUE(renamed); ASSERT_TRUE(wrapper); + ASSERT_FALSE(recursive); ASSERT_TRUE(renamed_class); PASS(); } diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index a5531452d..5a78fa79c 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -2771,6 +2771,125 @@ TEST(tslsp_crossfile_chain_through_return) { PASS(); } +/* The call target and its return signature must use the same exported name, + * even when the module also exports a function named like the local alias. */ +static int ts_renamed_import_return_chain(bool shared_registry) { + const char *source = "import { make as alias } from './lib';\n" + "function go() { alias().read(); }\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.main.go", + .short_name = "go", + .label = "Function", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.main"}, + {.qualified_name = "test.lib.Good", + .short_name = "Good", + .label = "Class", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.lib", + .method_names_str = "read"}, + {.qualified_name = "test.lib.Good.read", + .short_name = "read", + .label = "Method", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.lib", + .receiver_type = "test.lib.Good"}, + {.qualified_name = "test.lib.Bad", + .short_name = "Bad", + .label = "Class", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.lib", + .method_names_str = "read"}, + {.qualified_name = "test.lib.Bad.read", + .short_name = "read", + .label = "Method", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.lib", + .receiver_type = "test.lib.Bad"}, + {.qualified_name = "test.lib.make", + .short_name = "make", + .label = "Function", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.lib", + .return_types = "test.lib.Good"}, + {.qualified_name = "test.lib.alias", + .short_name = "alias", + .label = "Function", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.lib", + .return_types = "test.lib.Bad"}, + }; + enum { NDEFS = (int)(sizeof(defs) / sizeof(defs[0])) }; + const char *imp_names[] = {"alias"}; + const char *imp_qns[] = {"test.lib"}; + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + if (shared_registry) { + CBMTypeRegistry *reg = cbm_ts_build_cross_registry(&arena, defs, NDEFS); + ASSERT_NOT_NULL(reg); + cbm_run_ts_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", false, + false, false, reg, defs, NDEFS, imp_names, imp_qns, 1, + NULL, &out); + } else { + cbm_run_ts_lsp_cross(&arena, source, (int)strlen(source), "test.main", false, false, false, + defs, NDEFS, imp_names, imp_qns, 1, NULL, &out); + } + bool direct = has_exact_resolved(&out, "test.main.go", "test.lib.make", "lsp_import_alias"); + bool good = has_exact_resolved(&out, "test.main.go", "test.lib.Good.read", "lsp_ts_method"); + bool bad = has_exact_resolved(&out, "test.main.go", "test.lib.Bad.read", "lsp_ts_method"); + cbm_arena_destroy(&arena); + ASSERT_TRUE(direct); + ASSERT_FALSE(bad); + ASSERT_TRUE(good); + PASS(); +} + +TEST(tslsp_crossfile_renamed_import_return_chain) { + return ts_renamed_import_return_chain(false); +} + +TEST(tslsp_crossfile_renamed_import_return_chain_shared) { + return ts_renamed_import_return_chain(true); +} + +/* Keep the supplied import map at one entry: this measures the AST alias + * collector independently of the older import-map insertion path. */ +static size_t ts_import_alias_arena_bytes(int count) { + size_t capacity = (size_t)count * 48 + 64; + char *source = malloc(capacity); + if (!source) + return 0; + size_t len = (size_t)snprintf(source, capacity, "import { "); + for (int i = 0; i < count; i++) { + len += (size_t)snprintf(source + len, capacity - len, "%sf%d as a%d", i ? ", " : "", i, i); + } + len += (size_t)snprintf(source + len, capacity - len, " } from './lib';\n"); + const char *imp_names[] = {"a0"}; + const char *imp_qns[] = {"test.lib"}; + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + cbm_run_ts_lsp_cross(&arena, source, (int)len, "test.main", false, false, false, NULL, 0, + imp_names, imp_qns, 1, NULL, &out); + size_t bytes = cbm_arena_total(&arena); + cbm_arena_destroy(&arena); + free(source); + return bytes; +} + +TEST(tslsp_import_alias_arena_growth) { + size_t small = ts_import_alias_arena_bytes(1024); + size_t large = ts_import_alias_arena_bytes(2048); + ASSERT_GT(small, 0); + ASSERT_GT(large, small); + printf(" aliases arena bytes: 1024=%zu 2048=%zu ", small, large); + /* Linear source/AST work plus geometric storage stays below 3x; retaining + * two complete arrays at every insertion grows approximately 4x. */ + ASSERT(large < small * 3); + PASS(); +} + TEST(tslsp_crossfile_no_def_match) { /* Function that imports a symbol not in the def list — should resolve safely * (emit unresolved, no crash). */ @@ -5236,6 +5355,9 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_scale_many_defs_no_crash_issue344); RUN_TEST(tslsp_crossfile_function_call); RUN_TEST(tslsp_crossfile_chain_through_return); + RUN_TEST(tslsp_crossfile_renamed_import_return_chain); + RUN_TEST(tslsp_crossfile_renamed_import_return_chain_shared); + RUN_TEST(tslsp_import_alias_arena_growth); RUN_TEST(tslsp_crossfile_no_def_match); RUN_TEST(tslsp_crossfile_renamed_import_issue2297); RUN_TEST(tslsp_crossfile_renamed_import_missing_export_issue2297);