Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions internal/cbm/lsp/py_lsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/pipeline/lsp_resolve.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
85 changes: 85 additions & 0 deletions tests/test_lang_contract.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 6 additions & 10 deletions tests/test_py_lsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading