diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 4fc087ab2..d78b71904 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -206,8 +206,19 @@ void py_lsp_bind_imports(PyLSPContext *ctx) { // resolution checks the registry to upgrade to MODULE / class // / function as appropriate. t = cbm_type_named(ctx->arena, qn); + } else if (strchr(qn, '.') != NULL) { + // Dotted path whose tail does NOT match the local name: an + // ALIASED binding — `from X import Y as Z` (Z names function/ + // class X.Y) or `import a.b as z` (z names module a.b). The + // CBMImport shape cannot distinguish the two, but NAMED(qn) + // covers both: phase 6 upgrades it to the registered function/ + // class for the from-import and to MODULE for the module alias. + // Binding MODULE here made `g()` calls on `from m import f as g` + // resolve as calls on a module — lsp=MISS, and the whole CALLS + // edge was lost (#988). + t = cbm_type_named(ctx->arena, qn); } else { - // `import X` / `import X as Y` — bind to MODULE(X). + // `import X` / `import X as Y` (single segment) — MODULE(X). t = cbm_type_module(ctx->arena, qn); } py_scope_bind(ctx, local, t); @@ -1910,11 +1921,23 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { py_scope_restore(ctx, saved); return; } - // Constructor call (ClassName()) + // Constructor call (ClassName()) — or a call through any other + // NAMED scope binding (aliased imports land here too). const CBMType *in_scope = cbm_scope_lookup(ctx->current_scope, fname); if (!cbm_type_is_unknown(in_scope) && in_scope->kind == CBM_TYPE_NAMED) { const char *qn = in_scope->data.named.qualified_name; - py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f); + const char *tail = strrchr(qn, '.'); + const char *qn_short = tail ? tail + 1 : qn; + if (strcmp(qn_short, fname) != 0) { + /* Aliased binding (`from m import f as g; g()`): the textual + * callee ("g") differs from the resolved QN's tail ("f"), so + * the pass join would never match them. Stash the textual + * name in `reason` under a join-gated strategy, mirroring + * lsp_dict_dispatch (see lsp_resolve.h) (#988). */ + py_emit_resolved_call_reason(ctx, qn, "lsp_import_alias", 0.85f, fname); + } else { + py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f); + } return; } // Module-local function diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index b0809a822..2bb72e8f9 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -174,6 +174,7 @@ static inline const CBMResolvedCall *cbm_pipeline_find_lsp_resolution( strcmp(rc->strategy, "lsp_method_ref_ctor") == 0 || strcmp(rc->strategy, "lsp_method_ref_ctor_synth") == 0 || strcmp(rc->strategy, "lsp_dict_dispatch") == 0 || + strcmp(rc->strategy, "lsp_import_alias") == 0 || strcmp(rc->strategy, "lsp_destructor") == 0 || strcmp(rc->strategy, "php_method_dynamic") == 0) && strcmp(cbm_lsp_bare_segment(rc->reason), call_short) == 0)) { diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index f9f91066f..304fd45fe 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -1202,6 +1202,90 @@ TEST(contract_edge_imports_alias_resolves_real_file_issue767) { PASS(); } +/* True if a CALLS edge exists whose source QN ends with `src_suffix` and + * target QN ends with `tgt_suffix`. */ +static int calls_edge_between(cbm_store_t *store, const char *project, const char *src_suffix, + const char *tgt_suffix) { + cbm_edge_t *edges = NULL; + int n = 0; + if (cbm_store_find_edges_by_type(store, project, "CALLS", &edges, &n) != CBM_STORE_OK) + return 0; + int found = 0; + size_t ssl = strlen(src_suffix); + size_t tsl = strlen(tgt_suffix); + for (int i = 0; i < n && !found; i++) { + cbm_node_t s, t; + if (cbm_store_find_node_by_id(store, edges[i].source_id, &s) != CBM_STORE_OK) + continue; + if (cbm_store_find_node_by_id(store, edges[i].target_id, &t) != CBM_STORE_OK) { + cbm_node_free_fields(&s); + continue; + } + const char *sq = s.qualified_name; + const char *tq = t.qualified_name; + if (sq && tq) { + size_t sql = strlen(sq); + size_t tql = strlen(tq); + if (sql >= ssl && strcmp(sq + sql - ssl, src_suffix) == 0 && tql >= tsl && + strcmp(tq + tql - tsl, tgt_suffix) == 0) + found = 1; + } + cbm_node_free_fields(&s); + cbm_node_free_fields(&t); + } + cbm_store_free_edges(edges, n); + return found; +} + +/* #988: `from m import f as g; g()` produced NO CALLS edge — the Python LSP + * bound the alias as MODULE("m.f") (the from-style heuristic keys on the + * local name matching the module-path tail, which an alias never does), so + * the call missed, and the registry fallback did not cover the minimal + * two-file shape either. The alias must resolve exactly like the plain + * import. The other import forms are pinned alongside as invariance guards + * (all resolve on main today and must keep resolving). */ +TEST(contract_edge_python_aliased_import_call_resolves_issue988) { + LangProj lp; + static const LangFile f[] = { + {"m.py", "def f(x):\n return x + 1\n"}, + {"pkg/__init__.py", ""}, + {"pkg/dm.py", "def h(x):\n return x\n"}, + {"caller_alias.py", "from m import f as g\n" + "\n" + "def use_alias(x):\n" + " return g(x)\n"}, + {"caller_plain.py", "from m import f\n" + "\n" + "def use_plain(x):\n" + " return f(x)\n"}, + {"caller_modalias.py", "import m as mm\n" + "\n" + "def use_modalias(x):\n" + " return mm.f(x)\n"}, + {"caller_dotalias.py", "import pkg.dm as dz\n" + "\n" + "def use_dotalias(x):\n" + " return dz.h(x)\n"}}; + cbm_store_t *store = lang_index_files(&lp, f, 7); + ASSERT_TRUE(store != NULL); + int alias = calls_edge_between(store, lp.project, ".use_alias", ".m.f"); + int plain = calls_edge_between(store, lp.project, ".use_plain", ".m.f"); + int modalias = calls_edge_between(store, lp.project, ".use_modalias", ".m.f"); + int dotalias = calls_edge_between(store, lp.project, ".use_dotalias", ".pkg.dm.h"); + if (!alias || !plain || !modalias || !dotalias) { + fprintf(stderr, + " [988] FAIL alias=%d plain=%d modalias=%d dotalias=%d (all import forms " + "must produce the CALLS edge)\n", + alias, plain, modalias, dotalias); + } + ASSERT_TRUE(alias); /* the #988 bug: aliased from-import call lost */ + ASSERT_TRUE(plain); + ASSERT_TRUE(modalias); + ASSERT_TRUE(dotalias); + lang_cleanup(&lp, store); + PASS(); +} + /* DEPENDS_ON — Helm Chart.yaml `dependencies:` -> per-dependency Chart node. * Basename must be exactly "Chart.yaml"; pass_k8s runs in both pipeline paths. */ TEST(contract_edge_depends_on) { @@ -1411,6 +1495,7 @@ SUITE(lang_contract) { RUN_TEST(contract_edge_workspaces_imports_issue408); RUN_TEST(contract_edge_imports_alias_no_phantom_folder_edge_issue767); RUN_TEST(contract_edge_imports_alias_resolves_real_file_issue767); + RUN_TEST(contract_edge_python_aliased_import_call_resolves_issue988); RUN_TEST(contract_edge_depends_on); RUN_TEST(contract_edge_parallel_service_edges); RUN_TEST(contract_edge_file_changes_with); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 1dbdb7459..818e75f59 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -192,16 +192,12 @@ TEST(pylsp_import_from_aliased) { const char *qns[] = {"pathlib.Path"}; bind_imports_into_ctx(&ctx, &a, ®, locals, qns, 1); const CBMType *t = py_lsp_lookup_in_scope(&ctx, "P"); - /* Aliased name doesn't end module_qn with .P, so this binds as MODULE. - * Phase 6 registry lookup will downgrade to NAMED if registry has no - * matching module entry. Both behaviors are acceptable for v1; the - * test asserts the entry exists with the correct QN. */ - ASSERT(t->kind == CBM_TYPE_NAMED || t->kind == CBM_TYPE_MODULE); - if (t->kind == CBM_TYPE_NAMED) { - ASSERT_STR_EQ(t->data.named.qualified_name, "pathlib.Path"); - } else { - ASSERT_STR_EQ(t->data.module.module_qn, "pathlib.Path"); - } + /* #988: an aliased from-import MUST bind NAMED (phase 6 upgrades it to + * the registered function/class/module). The earlier MODULE binding made + * `g()` calls on `from m import f as g` resolve as calls on a module — + * lsp=MISS and the CALLS edge was lost. */ + ASSERT_EQ(t->kind, CBM_TYPE_NAMED); + ASSERT_STR_EQ(t->data.named.qualified_name, "pathlib.Path"); cbm_arena_destroy(&a); PASS(); }