Merge pull request #2679 from DeusData/fix/py-dotted-base-qn

fix(python): resolve a base class spelled through a root-binding import
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-10-18 21:01:14 +02:00
commit 5f66c867ed
2 files changed
+184

No files matched your search

+65
View File
@@ -238,6 +238,67 @@ typedef struct {
CBMLanguage lang;
} pxc_base_scope_t;
/* A type-like graph node, or NULL. */
static const cbm_gbuf_node_t *pxc_type_node(const cbm_gbuf_node_t *node) {
return node && node->qualified_name && cbm_label_is_type_like(node->label) ? node : NULL;
}
/* Python `import a.b.c` binds the ROOT name `a`, so a base spelled through it
* (`class X(a.b.c.Base)`) is module a.b.c's Base. The import map holds the
* imported MODULE under that root local (the IMPORTS edge target), and the
* registry's import_map step appends the whole remaining spelling to it
* (a.b.c + .b.c.Base), so a dotted base never resolved and every member
* inherited through it was invisible to the LSP. Resolve the spelling against
* the module the import names: the import map's module when that local is
* unique there, else the dotted module path under the project's QN rule.
* Import-backed and exact -- no short-name step -- and only a type-like
* project node is accepted. `import a.b as m` and `from a import b` bind the
* module itself and keep resolving through the import map. */
static const char *pxc_python_dotted_base_qn(const pxc_base_scope_t *bs, const char *raw) {
if (bs->lang != CBM_LANG_PYTHON || !bs->imports || !bs->gbuf || !strchr(raw, '.')) {
return NULL;
}
size_t root_len = strcspn(raw, ".");
for (int i = 0; i < bs->imports->count; i++) {
const CBMImport *imp = &bs->imports->items[i];
const char *local = imp->local_name;
const char *path = imp->module_path;
if (!local || !path || strlen(local) != root_len || strncmp(raw, local, root_len) != 0 ||
strncmp(path, local, root_len) != 0 ||
(path[root_len] != '\0' && path[root_len] != '.')) {
continue; /* not a root binding of this base's first segment */
}
size_t path_len = strlen(path);
if (strncmp(raw, path, path_len) != 0 || raw[path_len] != '.') {
continue; /* the base does not extend this import's module path */
}
const char *module_qn = NULL;
int hits = 0;
for (int k = 0; k < bs->imp_count; k++) {
if (bs->imp_keys[k] && bs->imp_vals[k] && strcmp(bs->imp_keys[k], local) == 0) {
module_qn = bs->imp_vals[k];
hits++;
}
}
const cbm_gbuf_node_t *node = NULL;
if (hits == 1) {
char candidate[CBM_SZ_512];
int n = snprintf(candidate, sizeof(candidate), "%s%s", module_qn, raw + path_len);
if (n > 0 && (size_t)n < sizeof(candidate)) {
node = pxc_type_node(cbm_gbuf_find_by_qn(bs->gbuf, candidate));
}
}
if (!node) {
node =
pxc_type_node(cbm_pipeline_lsp_target_node(bs->gbuf, bs->project_name, raw, false));
}
if (node) {
return node->qualified_name;
}
}
return NULL;
}
/* Resolve one base-class spelling to a project QN. Mirrors
* pass_semantic.c::resolve_as_class — same registry, same type-like veto —
* then additionally rejects weak short-name strategies (see above).
@@ -248,6 +309,10 @@ static const char *pxc_resolve_base_qn(const pxc_base_scope_t *bs, const char *r
if (!bs || !bs->reg || !raw || !raw[0]) {
return NULL;
}
const char *dotted = pxc_python_dotted_base_qn(bs, raw);
if (dotted) {
return dotted;
}
cbm_resolution_t res = cbm_registry_resolve_lang(bs->reg, raw, module_qn, bs->imp_keys,
bs->imp_vals, bs->imp_count, bs->lang);
if (!res.qualified_name || !res.qualified_name[0]) {
+119
View File
@@ -13628,6 +13628,123 @@ TEST(python_crossfile_typed_field_calls_issue1277_parallel) {
return check_python_crossfile_typed_field_calls_issue1277(true);
}
/* A base class spelled through a root-binding import (`import pkg.mod.nodes`
* binds `pkg`; `class X(pkg.mod.nodes.Context)`) is the same class as the
* from-imported spelling. The Python LSP's inheritance view never resolved the
* dotted spelling, so a member inherited through it (self.m(), super().m(),
* super().__init__()) fell to a weak name guess or to nothing. The plain
* from-import control must keep resolving; the untyped receiver must not. */
static int check_python_dotted_base_inheritance(bool parallel) {
const char *nodes = "class Context:\n"
" def __init__(self, line=-1):\n"
" self.line = line\n\n"
" def ctx_only_helper(self):\n"
" return 1\n\n"
" def describe(self):\n"
" return 0\n";
const char *types = "import pkg.mod.nodes\n"
"from pkg.mod.nodes import Context\n\n"
"class DottedType(pkg.mod.nodes.Context):\n"
" def __init__(self, line=-1):\n"
" super().__init__(line)\n\n"
" def run(self):\n"
" return self.ctx_only_helper()\n\n"
" def describe(self):\n"
" return super().describe()\n\n"
"class PlainType(Context):\n"
" def run(self):\n"
" return self.ctx_only_helper()\n\n"
"def untyped(thing):\n"
" return thing.ctx_only_helper()\n";
if (setup_usages_repo("pkg/mod/nodes.py", nodes, "pkg/types.py", types) != 0) {
FAIL("failed to create temp dir");
}
char path[512];
const char *inits[] = {"pkg/__init__.py", "pkg/mod/__init__.py"};
for (size_t i = 0; i < sizeof(inits) / sizeof(inits[0]); i++) {
snprintf(path, sizeof(path), "%s/%s", g_usages_tmpdir, inits[i]);
if (th_write_file(path, "") != 0) {
teardown_usages_repo();
FAIL("failed to write package marker");
}
}
if (parallel) {
for (int i = 0; i < 50; i++) {
snprintf(path, sizeof(path), "%s/dotted_pad_%02d.py", g_usages_tmpdir, i);
if (th_write_file(path, "dotted_padding = 0\n") != 0) {
teardown_usages_repo();
FAIL("failed to write parallel-selection fixture");
}
}
}
const char *old_workers = getenv("CBM_WORKERS");
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
char *saved_single = old_single ? strdup(old_single) : NULL;
if ((old_workers && !saved_workers) || (old_single && !saved_single)) {
free(saved_workers);
free(saved_single);
teardown_usages_repo();
FAIL("failed to save pipeline environment");
}
int pin_workers_rc = parallel ? cbm_setenv("CBM_WORKERS", "4", 1) : 0;
int pin_single_rc = parallel ? cbm_unsetenv("CBM_INDEX_SINGLE_THREAD") : 0;
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/test_dotted_base.db", g_usages_tmpdir);
cbm_pipeline_t *p = pin_workers_rc == 0 && pin_single_rc == 0
? cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL)
: NULL;
int run_rc = p ? cbm_pipeline_run(p) : -1;
cbm_store_t *s = run_rc == 0 ? cbm_store_open_path(db_path) : NULL;
bool store_opened = s != NULL;
int dotted_self = -1, dotted_super = -1, dotted_init = -1, plain_self = -1, untyped = -1;
if (s) {
const char *project = cbm_pipeline_project_name(p);
dotted_self = count_lsp_calls(s, project, "pkg.types.DottedType.run",
"pkg.mod.nodes.Context.ctx_only_helper");
dotted_super = count_lsp_calls(s, project, "pkg.types.DottedType.describe",
"pkg.mod.nodes.Context.describe");
dotted_init = count_lsp_calls(s, project, "pkg.types.DottedType.__init__",
"pkg.mod.nodes.Context.__init__");
plain_self = count_lsp_calls(s, project, "pkg.types.PlainType.run",
"pkg.mod.nodes.Context.ctx_only_helper");
untyped = count_lsp_calls(s, project, "pkg.types.untyped", "ctx_only_helper");
cbm_store_close(s);
}
cbm_pipeline_free(p);
int restore_workers_rc =
saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS");
int restore_single_rc = saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1)
: cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
free(saved_workers);
free(saved_single);
teardown_usages_repo();
ASSERT_EQ(pin_workers_rc, 0);
ASSERT_EQ(pin_single_rc, 0);
ASSERT_EQ(restore_workers_rc, 0);
ASSERT_EQ(restore_single_rc, 0);
ASSERT_EQ(run_rc, 0);
ASSERT_TRUE(store_opened);
ASSERT_EQ(dotted_self, 1); /* self.m() inherited through a dotted base */
ASSERT_EQ(dotted_super, 1); /* super().m() through a dotted base */
ASSERT_EQ(dotted_init, 1); /* super().__init__() through a dotted base */
ASSERT_EQ(plain_self, 1); /* control: from-imported base */
ASSERT_EQ(untyped, 0); /* control: no receiver type, no LSP edge */
PASS();
}
TEST(python_dotted_base_inheritance) {
return check_python_dotted_base_inheritance(false);
}
TEST(python_dotted_base_inheritance_parallel) {
return check_python_dotted_base_inheritance(true);
}
/* Bind the external-import veto specifically to #1277's typed-field fold.
* No base classes or CALLS edges participate: a name-only call fallback
* cannot turn this metadata assertion into a false positive. The unguarded
@@ -23778,6 +23895,8 @@ SUITE(pipeline) {
RUN_TEST(override_python_through_intermediate_class);
RUN_TEST(python_crossfile_typed_field_calls_issue1277);
RUN_TEST(python_crossfile_typed_field_calls_issue1277_parallel);
RUN_TEST(python_dotted_base_inheritance);
RUN_TEST(python_dotted_base_inheritance_parallel);
RUN_TEST(python_typed_field_external_import_stays_unresolved);
RUN_TEST(python_typed_field_project_class_resolves);
RUN_TEST(usages_creates_edges);