Merge pull request #1049 from DeusData/fix/988-py-alias-import

fix(py-lsp): resolve calls through aliased imports
This commit is contained in:
Martin Vogel
2026-07-16 19:43:18 +02:00
committed by GitHub
4 changed files with 118 additions and 13 deletions
+26 -3
View File
@@ -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
+1
View File
@@ -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)) {
+85
View File
@@ -1207,6 +1207,41 @@ 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;
}
/* #999: URLs in CI/tooling configs (.pre-commit-config.yaml, .github/
* workflows) are repository references for TOOLING, not endpoints this
* service exposes. They must not mint __route__infra__ Route nodes — the
@@ -1327,6 +1362,55 @@ static int calls_edge_targets(cbm_store_t *store, const char *project, const cha
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();
}
/* #871: a CommonJS require() binding shadowed call resolution. `const doThing
* = require("../bs/doThing")` emitted a Variable def for `doThing` in the
* importing module, so the call `doThing(...)` resolved to that same-module
@@ -1572,6 +1656,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_no_infra_routes_from_ci_configs_issue999);
RUN_TEST(contract_edge_infra_routes_from_deploy_configs_still_minted);
RUN_TEST(contract_edge_commonjs_require_call_resolves_issue871);
+6 -10
View File
@@ -192,16 +192,12 @@ TEST(pylsp_import_from_aliased) {
const char *qns[] = {"pathlib.Path"};
bind_imports_into_ctx(&ctx, &a, &reg, 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();
}