From 51a3e4e7b559e416c2eaff6caeacfca3a368dabe Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 17 Oct 2026 22:52:58 +0200 Subject: [PATCH 1/2] fix(python): resolve a base class spelled through a root-binding import `import pkg.mod.nodes` binds the ROOT name `pkg`, so `class X(pkg.mod.nodes.Context)` names module pkg.mod.nodes's Context. The cross-LSP base resolution asked the registry, whose import_map step joined the imported MODULE (the IMPORTS edge target, pkg.mod.nodes) with the whole remaining spelling (mod.nodes.Context) and missed; the base kept its raw spelling, so the Python LSP's inheritance view never saw it. Every member inherited through a dotted base fell to a name guess or to nothing: self.m() became unique_name, super().m() suffix_match, super().__init__() only resolved through a synthesized QN. pxc_resolve_base_qn now resolves such a spelling against the module the import names: the import map's module when the root local is unique there, else the dotted module path under the project's QN rule. Python only, 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. Tests: python_dotted_base_inheritance(_parallel): self.m(), super().m() and super().__init__() through a dotted base resolve by the LSP; the from-imported control keeps resolving; an untyped receiver stays without an LSP edge. Signed-off-by: Martin Vogel --- src/pipeline/pass_lsp_cross.c | 63 ++++++++++++++++++ tests/test_pipeline.c | 119 ++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index cf94926c0..bc48d75ae 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -238,6 +238,65 @@ 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 +307,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]) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b52513ace..33b71d7d9 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -13517,6 +13517,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 @@ -23665,6 +23782,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); From cc79e1d63fe0cd3906a0d00361280df0dda7c2a7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 02:51:25 +0200 Subject: [PATCH 2/2] style(pipeline): clang-format the dotted-base resolution Whitespace only; the token stream is unchanged. Signed-off-by: Martin Vogel --- src/pipeline/pass_lsp_cross.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index bc48d75ae..9ad97bee8 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -264,7 +264,8 @@ static const char *pxc_python_dotted_base_qn(const pxc_base_scope_t *bs, const c 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] != '.')) { + 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); @@ -288,7 +289,8 @@ static const char *pxc_python_dotted_base_qn(const pxc_base_scope_t *bs, const c } } if (!node) { - node = pxc_type_node(cbm_pipeline_lsp_target_node(bs->gbuf, bs->project_name, raw, false)); + node = + pxc_type_node(cbm_pipeline_lsp_target_node(bs->gbuf, bs->project_name, raw, false)); } if (node) { return node->qualified_name;