From e106c415cf583f99f1cb8cb69ec713077f68e919 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 03:18:15 +0200 Subject: [PATCH 01/17] fix(python): from . import x names the importing package, not its parent A package-relative from-import (`from . import x`, `from .. import x`, `from . import x as y`) has a module made only of dots. The extractor joined it to the imported name with another '.', recording "..x" for `from . import x`: the parent package. The import resolver then missed the real binding and fell to the symbol-name guess (the lexicographically first same-named definition in the project), so the import map, and the Python LSP binding that consumes it, sent every call to an unrelated function at import_map / lsp_callable_alias confidence. On home-assistant, 7,175 caller/callee pairs bound to another integration's function (webhook's `async_register` to accuweather's). Join without the extra separator when the module already ends in '.'. `from .m import f` (".m.f") and absolute imports are unchanged. Tests: python_package_relative_import_paths (extraction: the recorded paths); pipeline_python_package_relative_imports{,_parallel} (all three forms resolve to the own / parent package, never to the namesake that sorts first, without a name guess, on both resolvers); pipeline_python_from_import_bare_call_issue1237 {,_parallel} (#1237 guard: a plain from-import and bare calls bind the imported def and keep it across a re-index that adds a file). Signed-off-by: Martin Vogel --- internal/cbm/extract_imports.c | 17 ++- tests/test_extraction.c | 43 ++++++ tests/test_pipeline.c | 256 +++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 2 deletions(-) diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 576bdb8f1..e7dba1e05 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -123,6 +123,19 @@ static const char *python_import_root(CBMArena *a, const char *path) { return dot ? cbm_arena_strndup(a, path, (size_t)(dot - path)) : path; } +/* Spell `from import ` as one dotted path. A package-relative + * module (`from . import x`, `from .. import x`) is nothing but dots, which + * already end in the separator: joining with another '.' would add a level and + * name the PARENT package ("..x"), so the import resolved one package too high + * and fell to a same-named definition anywhere in the project. */ +static const char *python_join_from_import(CBMArena *a, const char *module, const char *name) { + size_t len = strlen(module); + if (len > 0 && module[len - SKIP_ONE] == '.') { + return cbm_arena_sprintf(a, "%s%s", module, name); + } + return cbm_arena_sprintf(a, "%s.%s", module, name); +} + // --- Go imports --- // import_declaration -> import_spec_list -> import_spec -> (name, path) @@ -209,7 +222,7 @@ static void emit_py_aliased_import(CBMExtractCtx *ctx, TSNode child, const char } const char *local = !ts_node_is_null(alias_node) ? cbm_node_text(a, alias_node, ctx->source) : path_last(a, name); - const char *full = mod_prefix ? cbm_arena_sprintf(a, "%s.%s", mod_prefix, name) : name; + const char *full = mod_prefix ? python_join_from_import(a, mod_prefix, name) : name; CBMImport imp = {.local_name = local, .module_path = full}; cbm_imports_push(&ctx->result->imports, a, imp); } @@ -267,7 +280,7 @@ static void emit_py_import_from_name(CBMExtractCtx *ctx, TSNode child, const cha CBMArena *a = ctx->arena; char *name = cbm_node_text(a, child, ctx->source); if (name && name[0]) { - const char *full = mod_path ? cbm_arena_sprintf(a, "%s.%s", mod_path, name) : name; + const char *full = mod_path ? python_join_from_import(a, mod_path, name) : name; CBMImport imp = {.local_name = name, .module_path = full}; cbm_imports_push(&ctx->result->imports, a, imp); } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index e39d40a8a..1fdfda250 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3790,6 +3790,48 @@ TEST(python_imports) { PASS(); } +/* The module_path recorded for a local name, or NULL. */ +static const char *import_path_of(CBMFileResult *r, const char *local) { + for (int i = 0; i < r->imports.count; i++) { + if (r->imports.items[i].local_name && strcmp(r->imports.items[i].local_name, local) == 0) + return r->imports.items[i].module_path; + } + return NULL; +} + +/* `from . import x` names the importing file's OWN package: the path is ".x", + * as `from .m import f` is ".m.f". Joining the all-dots module with another '.' + * recorded "..x" (the parent package), so the import resolved one package too + * high and fell to an arbitrary same-named definition (home-assistant: 7,175 + * calls bound to another integration's function). */ +TEST(python_package_relative_import_paths) { + CBMFileResult *r = extract("from . import async_register\n" + "from .. import shared\n" + "from . import local_fn as lf\n" + "from .helpers import helper_fn\n" + "from pkg.db import get_row\n", + CBM_LANG_PYTHON, "t", "comp/web/sub/mod.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const char *own = import_path_of(r, "async_register"); + const char *parent = import_path_of(r, "shared"); + const char *aliased = import_path_of(r, "lf"); + const char *member = import_path_of(r, "helper_fn"); + const char *absolute = import_path_of(r, "get_row"); + ASSERT_NOT_NULL(own); + ASSERT_NOT_NULL(parent); + ASSERT_NOT_NULL(aliased); + ASSERT_NOT_NULL(member); + ASSERT_NOT_NULL(absolute); + ASSERT_STR_EQ(own, ".async_register"); + ASSERT_STR_EQ(parent, "..shared"); + ASSERT_STR_EQ(aliased, ".local_fn"); + ASSERT_STR_EQ(member, ".helpers.helper_fn"); /* control: unchanged */ + ASSERT_STR_EQ(absolute, "pkg.db.get_row"); /* control: unchanged */ + cbm_free_result(r); + PASS(); +} + TEST(js_imports) { CBMFileResult *r = extract("import React from 'react';\nimport { useState } from " "'react';\nconst fs = require('fs');\n", @@ -12497,6 +12539,7 @@ SUITE(extraction) { RUN_TEST(python_iris_invokeClassMethod); RUN_TEST(go_calls); RUN_TEST(python_imports); + RUN_TEST(python_package_relative_import_paths); RUN_TEST(js_imports); RUN_TEST(go_imports); RUN_TEST(go_cgo_pseudo_import_dropped); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b52513ace..fe52949ca 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -10245,6 +10245,258 @@ TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge) { PASS(); } +/* CALLS edges from a caller to a callee, both matched by QN suffix (start each + * suffix with '.' to anchor it at a segment boundary), any strategy. *guessed + * is set when a matching edge came from a name guess rather than an import + * binding or the LSP. */ +static int py_calls_between(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, bool *guessed) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count); + int hits = 0; + size_t cs = strlen(caller_suffix); + size_t ts = strlen(callee_suffix); + for (int i = 0; i < edge_count; i++) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK && + cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK) { + size_t sl = strlen(src.qualified_name); + size_t tl = strlen(tgt.qualified_name); + if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 && + strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0) { + hits++; + const char *pj = edges[i].properties_json ? edges[i].properties_json : ""; + if (guessed && !strstr(pj, "\"import_map\"") && !strstr(pj, "\"lsp")) { + *guessed = true; + } + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) + cbm_store_free_edges(edges, edge_count); + return hits; +} + +/* Pin CBM_WORKERS=4 for a padded (> 50 files) parallel run; restore after. */ +typedef struct { + char *saved; + bool had; + int rc; +} py_workers_pin_t; + +static py_workers_pin_t py_pin_workers(bool parallel) { + py_workers_pin_t pin = {0}; + if (!parallel) { + return pin; + } + const char *old = getenv("CBM_WORKERS"); + pin.had = old != NULL; + pin.saved = old ? strdup(old) : NULL; + pin.rc = (old && !pin.saved) ? -1 : cbm_setenv("CBM_WORKERS", "4", 1); + return pin; +} + +static int py_unpin_workers(bool parallel, py_workers_pin_t *pin) { + if (!parallel) { + return 0; + } + int rc = pin->had ? cbm_setenv("CBM_WORKERS", pin->saved, 1) : cbm_unsetenv("CBM_WORKERS"); + free(pin->saved); + pin->saved = NULL; + return rc; +} + +static void py_write_padding(const char *tmp, const char *stem, bool parallel) { + for (int i = 0; parallel && i < 52; i++) { + char name[64]; + char body[96]; + snprintf(name, sizeof(name), "%s%d.py", stem, i); + snprintf(body, sizeof(body), "def %s%d():\n return %d\n", stem, i, i); + write_temp_file(tmp, name, body); + } +} + +/* `from . import x`, `from .. import x` and `from . import x as y` name the + * importing file's own (or parent) package. The extractor recorded them one + * package too high ("..x"), so the import fell to pass_pkgmap's symbol-name + * guess -- the lexicographically first same-named project definition -- and + * every call became a confident edge into comp.aaa, through the import map and + * the Python LSP's alias binding alike (home-assistant: 7,175 call pairs). + * Both resolvers: <= 50 files sequential, padded > 50 parallel. */ +static int check_python_package_relative_imports(bool parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_relpkg_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "comp/__init__.py", ""); + write_temp_file(tmp, "comp/aaa/__init__.py", + "def async_register(hass):\n" + " return 1\n\n" + "def local_fn():\n" + " return 2\n"); + write_temp_file(tmp, "comp/web/__init__.py", + "def async_register(hass):\n" + " return 3\n"); + write_temp_file(tmp, "comp/web/trigger.py", + "from . import async_register\n\n" + "def attach(hass):\n" + " return async_register(hass)\n"); + write_temp_file(tmp, "comp/web/sub/__init__.py", + "def local_fn():\n" + " return 4\n"); + write_temp_file(tmp, "comp/web/sub/mod.py", + "from .. import async_register\n" + "from . import local_fn as lf\n\n" + "def deep(hass):\n" + " async_register(hass)\n" + " return lf()\n"); + py_write_padding(tmp, "relpad", parallel); + + py_workers_pin_t pin = py_pin_workers(parallel); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/relpkg.db", tmp); + cbm_pipeline_t *p = pin.rc == 0 ? cbm_pipeline_new(tmp, 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 opened = s != NULL; + bool guessed = false; + int own = -1, own_decoy = -1, parent = -1, parent_decoy = -1, alias = -1, alias_decoy = -1; + if (s) { + const char *project = cbm_pipeline_project_name(p); + own = py_calls_between(s, project, ".comp.web.trigger.attach", ".comp.web.async_register", + &guessed); + own_decoy = py_calls_between(s, project, ".comp.web.trigger.attach", + ".comp.aaa.async_register", NULL); + parent = py_calls_between(s, project, ".comp.web.sub.mod.deep", ".comp.web.async_register", + &guessed); + parent_decoy = py_calls_between(s, project, ".comp.web.sub.mod.deep", + ".comp.aaa.async_register", NULL); + alias = py_calls_between(s, project, ".comp.web.sub.mod.deep", ".comp.web.sub.local_fn", + &guessed); + alias_decoy = + py_calls_between(s, project, ".comp.web.sub.mod.deep", ".comp.aaa.local_fn", NULL); + cbm_store_close(s); + } + cbm_pipeline_free(p); + int unpin_rc = py_unpin_workers(parallel, &pin); + th_rmtree(tmp); + + ASSERT_EQ(pin.rc, 0); + ASSERT_EQ(unpin_rc, 0); + ASSERT_EQ(run_rc, 0); + ASSERT_TRUE(opened); + ASSERT_EQ(own, 1); /* from . import x: the file's own package */ + ASSERT_EQ(own_decoy, 0); /* never the lexicographically first namesake */ + ASSERT_EQ(parent, 1); /* from .. import x: the parent package */ + ASSERT_EQ(parent_decoy, 0); + ASSERT_EQ(alias, 1); /* from . import x as y */ + ASSERT_EQ(alias_decoy, 0); + ASSERT_FALSE(guessed); /* bound by the import, not by a name guess */ + PASS(); +} + +TEST(pipeline_python_package_relative_imports) { + return check_python_package_relative_imports(false); +} + +TEST(pipeline_python_package_relative_imports_parallel) { + return check_python_package_relative_imports(true); +} + +/* #1237: a plain `from pkg.db import get_row_or_none` and bare calls resolve to + * the imported module-level def (function and method callers, plus the + * same-file caller), never to a same-named def elsewhere that a name guess + * would rank first (legacy.compat), and a re-index that only ADDS an unrelated + * file (the reporter's watcher re-index after writing benchmark scripts into the + * indexed tree) keeps exactly the same CALLS. Main already resolved this shape + * when the guard landed: it pins the shape and has no RED on revert. */ +static int check_python_from_import_bare_call_issue1237(bool parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_1237_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "pkg/__init__.py", ""); + write_temp_file(tmp, "pkg/db.py", + "def get_row_or_none(model, **filters):\n" + " return None\n\n" + "def other_helper():\n" + " return get_row_or_none(1, id=2)\n"); + write_temp_file(tmp, "pkg/views.py", + "from pkg.db import get_row_or_none\n\n" + "def show(pk):\n" + " return get_row_or_none(object, id=pk)\n\n" + "class Detail:\n" + " def get(self, pk):\n" + " return get_row_or_none(object, pk=pk)\n"); + write_temp_file(tmp, "legacy/__init__.py", ""); + write_temp_file(tmp, "legacy/compat.py", + "def get_row_or_none(model):\n" + " return model\n"); + py_write_padding(tmp, "rowpad", parallel); + + py_workers_pin_t pin = py_pin_workers(parallel); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/rows.db", tmp); + int run_rc[2] = {-1, -1}; + int show[2] = {-1, -1}; + int method[2] = {-1, -1}; + int same[2] = {-1, -1}; + int decoy[2] = {-1, -1}; + int calls[2] = {-1, -1}; + bool guessed = false; + for (int run = 0; run < 2 && pin.rc == 0; run++) { + if (run == 1) { + /* Second run on the same db: routes to the incremental re-index. */ + write_temp_file(tmp, "bench_rows.py", "def bench():\n return 0\n"); + } + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + run_rc[run] = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = run_rc[run] == 0 ? cbm_store_open_path(db_path) : NULL; + if (s) { + const char *project = cbm_pipeline_project_name(p); + const char *target = ".pkg.db.get_row_or_none"; + const char *namesake = ".legacy.compat.get_row_or_none"; + show[run] = py_calls_between(s, project, ".pkg.views.show", target, &guessed); + method[run] = py_calls_between(s, project, ".pkg.views.Detail.get", target, &guessed); + same[run] = py_calls_between(s, project, ".pkg.db.other_helper", target, &guessed); + decoy[run] = py_calls_between(s, project, ".pkg.views.show", namesake, NULL) + + py_calls_between(s, project, ".pkg.views.Detail.get", namesake, NULL); + calls[run] = cbm_store_count_edges_by_type(s, project, "CALLS"); + cbm_store_close(s); + } + cbm_pipeline_free(p); + } + int unpin_rc = py_unpin_workers(parallel, &pin); + th_rmtree(tmp); + + ASSERT_EQ(pin.rc, 0); + ASSERT_EQ(unpin_rc, 0); + for (int run = 0; run < 2; run++) { + ASSERT_EQ(run_rc[run], 0); + ASSERT_EQ(show[run], 1); /* cross-file bare call through a plain from-import */ + ASSERT_EQ(method[run], 1); /* the same from a method */ + ASSERT_EQ(same[run], 1); /* same-file bare call */ + ASSERT_EQ(decoy[run], 0); /* never the namesake a name guess would pick */ + } + ASSERT_EQ(calls[1], calls[0]); /* adding a call-free file moves no CALLS edge */ + ASSERT_FALSE(guessed); + PASS(); +} + +TEST(pipeline_python_from_import_bare_call_issue1237) { + return check_python_from_import_bare_call_issue1237(false); +} + +TEST(pipeline_python_from_import_bare_call_issue1237_parallel) { + return check_python_from_import_bare_call_issue1237(true); +} + /* Reproduce-first: pass_parallel's fused cross-LSP eligibility currently counts * only parser-backed calls/call references. A Python binary operator has no * parser CBMCall; its __add__ semantic record and carrier are created together @@ -23630,6 +23882,10 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge); RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge); + RUN_TEST(pipeline_python_package_relative_imports); + RUN_TEST(pipeline_python_package_relative_imports_parallel); + RUN_TEST(pipeline_python_from_import_bare_call_issue1237); + RUN_TEST(pipeline_python_from_import_bare_call_issue1237_parallel); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); From 4b59b9a3b2eb66185d765447aa348ef73698e6d0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 18:33:11 +0200 Subject: [PATCH 02/17] test(python): a decorated class nested in a class body is extracted RED on its own: push_nested_class_nodes does not look through decorated_definition. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 90 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 40647a833..396c5ed3e 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15024,6 +15024,94 @@ TEST(pipeline_python_nested_helper_keeps_tests_parallel_issue1548) { return pyn_run_nested_helper_tests(52); } +/* A decorated class nested in a class body (`@dataclass class Meta:` inside a + * class) is a nested class like an undecorated one: it gets its Class node and + * its methods their Method nodes. push_nested_class_nodes looked through + * field/template/declaration and conditional wrappers but not through Python's + * decorated_definition, so the decorated class and everything in it was never + * extracted. Both resolvers. */ +static const char pyn_decorated_nested_py[] = "def deco(cls):\n" + " return cls\n" + "\n" + "class Outer:\n" + " @deco\n" + " class Decorated:\n" + " def run(self):\n" + " return 1\n" + "\n" + " class Plain:\n" + " def go(self):\n" + " return 2\n"; + +static bool pyn_node_exists(cbm_store_t *s, const char *project, const char *rel_qn, + const char *label) { + char qn[512]; + pyn_qn(qn, sizeof(qn), project, rel_qn); + cbm_node_t n = {0}; + bool found = cbm_store_find_node_by_qn(s, project, qn, &n) == CBM_STORE_OK && n.label && + strcmp(n.label, label) == 0; + cbm_node_free_fields(&n); + return found; +} + +static int pyn_run_decorated_nested(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_decnest_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "sd.py", pyn_decorated_nested_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/decnest.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + const char *project = p ? cbm_pipeline_project_name(p) : NULL; + bool dec_class = s && pyn_node_exists(s, project, "sd.Outer.Decorated", "Class"); + bool dec_method = s && pyn_node_exists(s, project, "sd.Outer.Decorated.run", "Method"); + bool plain_class = s && pyn_node_exists(s, project, "sd.Outer.Plain", "Class"); + bool plain_method = s && pyn_node_exists(s, project, "sd.Outer.Plain.go", "Method"); + if (s) { + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(dec_class); /* the decorated nested class is extracted */ + ASSERT_TRUE(dec_method); /* ... and its method */ + ASSERT_TRUE(plain_class); /* control: undecorated nested class */ + ASSERT_TRUE(plain_method); /* control */ + PASS(); +} + +TEST(pipeline_python_decorated_nested_class_extracted) { + return pyn_run_decorated_nested(0); +} + +TEST(pipeline_python_decorated_nested_class_extracted_parallel) { + return pyn_run_decorated_nested(52); +} + TEST(pipeline_python_project) { /* Port of TestPipelinePythonProject */ const char *files[] = {"main.py", "utils.py"}; @@ -24531,6 +24619,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548); RUN_TEST(pipeline_python_nested_helper_keeps_tests_issue1548); RUN_TEST(pipeline_python_nested_helper_keeps_tests_parallel_issue1548); + RUN_TEST(pipeline_python_decorated_nested_class_extracted); + RUN_TEST(pipeline_python_decorated_nested_class_extracted_parallel); RUN_TEST(pipeline_header_include_target_is_independent_of_registration_order); RUN_TEST(pipeline_imports_multi_symbol_edges); RUN_TEST(pipeline_go_cross_package_call); From 4d71327cfd1a2def1b89b822078fdf737c500bd6 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 19:05:52 +0200 Subject: [PATCH 03/17] fix(python): extract a decorated class nested in a class body push_nested_class_nodes descended into field/template/declaration and conditional wrappers but not into Python's decorated_definition, so a decorated nested class (`@dataclass class Meta:` inside a class) was never extracted: no Class node, no Method nodes, and the calls in it fell to the FILE node. Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 6491a677f..6cd935c50 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -11493,9 +11493,12 @@ static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, wd_sta wd_push(s, child, enclosing_qn); } else { const char *ck = ts_node_type(child); - /* A nested class inside #if/#else is still nested here. */ + /* A nested class inside #if/#else is still nested here, and so is + * a decorated one (Python `@dataclass class Meta:` in a class + * body): the decorated_definition wraps the class_definition. */ if (strcmp(ck, "field_declaration") == 0 || strcmp(ck, "template_declaration") == 0 || strcmp(ck, "declaration") == 0 || + (ctx->language == CBM_LANG_PYTHON && strcmp(ck, "decorated_definition") == 0) || cbm_kind_in_set(child, cbm_conditional_block_types(spec->language))) { ts_nstack_push(&nc_stack, child); } From e475115436d745a1cfbb13b3e9c9bea24a94d84d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 21:54:08 +0200 Subject: [PATCH 04/17] test(python): a nested class base names an earlier sibling of its class body The sqlalchemy asyncpg adapter re-declares the DBAPI exception hierarchy as classes nested in one class body, each based on an earlier sibling spelled bare (class IntegrityError(DatabaseError)). Python evaluates the bases in the enclosing class body, so the sibling wins over same-named module classes and over another adapter's nested namesakes; a sibling defined later is not bound yet. Sequential and parallel pipeline, each followed by an incremental re-index. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 157 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4f8c656ad..edfdc4552 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15220,6 +15220,161 @@ TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548) { return pyn_run_init_collision(52); } +/* ── F15: a nested class's bare base resolves in its class body first ── */ + +/* The sqlalchemy asyncpg shape: an adapter class re-declares the DBAPI + * exception hierarchy as nested classes, each based on an EARLIER sibling + * spelled bare (`class IntegrityError(DatabaseError)`). Python evaluates a + * class statement's bases in the enclosing class body, so the sibling wins + * over the same-named module classes of exc.py. A sibling defined LATER in + * the body is not bound yet: `Early(Later)` names the module-level Later. */ +static const char pyb_exc_py[] = "class DBAPIError(Exception):\n" + " pass\n" + "\n" + "class DatabaseError(DBAPIError):\n" + " pass\n" + "\n" + "class IntegrityError(DatabaseError):\n" + " pass\n"; + +static const char pyb_adapter_py[] = "from exc import DBAPIError\n" + "\n" + "class Later(Exception):\n" + " pass\n" + "\n" + "class AsyncAdapt_dbapi:\n" + " class Error(DBAPIError):\n" + " pass\n" + "\n" + " class DatabaseError(Error):\n" + " pass\n" + "\n" + " class IntegrityError(DatabaseError):\n" + " pass\n" + "\n" + " class NotNullViolationError(IntegrityError):\n" + " pass\n" + "\n" + " class Early(Later):\n" + " pass\n" + "\n" + " class Later(Exception):\n" + " pass\n"; + +/* A second adapter with the same nested names, as sqlalchemy has one per + * driver: a name-only guess has several same-named candidates to pick. */ +static const char pyb_adapter2_py[] = "class AsyncAdapt_other_dbapi:\n" + " class Error(Exception):\n" + " pass\n" + "\n" + " class DatabaseError(Error):\n" + " pass\n" + "\n" + " class IntegrityError(DatabaseError):\n" + " pass\n"; + +static int pyb_run_class_body_base(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_cbbase_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "exc.py", pyb_exc_py); + write_temp_file(tmp, "adapter.py", pyb_adapter_py); + write_temp_file(tmp, "adapter2.py", pyb_adapter2_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/cbbase.db", tmp); + int rc[2] = {-1, -1}; + bool db_sib[2] = {false, false}; + bool db_mod[2] = {true, true}; + bool ie_sib[2] = {false, false}; + bool ie_mod[2] = {true, true}; + bool nn_sib[2] = {false, false}; + bool err_imp[2] = {false, false}; + bool early_mod[2] = {false, false}; + bool early_later_sib[2] = {true, true}; + bool other_sib[2] = {false, false}; + for (int run = 0; run < 2; run++) { + if (run == 1) { + char path[512]; + snprintf(path, sizeof(path), "%s/adapter.py", tmp); + th_append_file(path, "\n# edited: the second run re-indexes incrementally\n"); + } + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + rc[run] = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc[run] == 0 ? cbm_store_open_path(db_path) : NULL; + if (s) { + const char *pr = cbm_pipeline_project_name(p); + const char *cls = "adapter.AsyncAdapt_dbapi"; + char a[256]; + char b[256]; + snprintf(a, sizeof(a), "%s.DatabaseError", cls); + snprintf(b, sizeof(b), "%s.Error", cls); + db_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + db_mod[run] = pyn_edge(s, pr, a, "adapter2.AsyncAdapt_other_dbapi.Error", "INHERITS"); + snprintf(a, sizeof(a), "%s.IntegrityError", cls); + snprintf(b, sizeof(b), "%s.DatabaseError", cls); + ie_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + ie_mod[run] = pyn_edge(s, pr, a, "exc.DatabaseError", "INHERITS"); + snprintf(a, sizeof(a), "%s.NotNullViolationError", cls); + snprintf(b, sizeof(b), "%s.IntegrityError", cls); + nn_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + snprintf(a, sizeof(a), "%s.Error", cls); + err_imp[run] = pyn_edge(s, pr, a, "exc.DBAPIError", "INHERITS"); + snprintf(a, sizeof(a), "%s.Early", cls); + snprintf(b, sizeof(b), "%s.Later", cls); + early_mod[run] = pyn_edge(s, pr, a, "adapter.Later", "INHERITS"); + early_later_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + other_sib[run] = pyn_edge(s, pr, "adapter2.AsyncAdapt_other_dbapi.IntegrityError", + "adapter2.AsyncAdapt_other_dbapi.DatabaseError", "INHERITS"); + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + for (int run = 0; run < 2; run++) { + ASSERT_EQ(rc[run], 0); + ASSERT_TRUE(db_sib[run]); /* DatabaseError(Error): the earlier sibling */ + ASSERT_FALSE(db_mod[run]); /* ... not the other adapter's Error */ + ASSERT_TRUE(ie_sib[run]); /* IntegrityError(DatabaseError): the sibling */ + ASSERT_FALSE(ie_mod[run]); /* ... not exc.DatabaseError */ + ASSERT_TRUE(nn_sib[run]); /* a chain of siblings */ + ASSERT_TRUE(err_imp[run]); /* control: a non-sibling base keeps the import */ + ASSERT_TRUE(early_mod[run]); /* a LATER sibling is not bound yet: module Later */ + ASSERT_FALSE(early_later_sib[run]); /* ... never the later sibling */ + ASSERT_TRUE(other_sib[run]); /* the second adapter binds its own sibling */ + } + PASS(); +} + +TEST(pipeline_python_class_body_sibling_base) { + return pyb_run_class_body_base(0); +} + +TEST(pipeline_python_class_body_sibling_base_parallel) { + return pyb_run_class_body_base(52); +} + /* #1548 keeps TESTS for calls made through a test's own nested helper: the * call now starts at the extracted helper def (not at the test FILE node, as * before #1548), so TESTS is attributed to the enclosing test function. A @@ -24826,6 +24981,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_issue1548); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548); + RUN_TEST(pipeline_python_class_body_sibling_base); + RUN_TEST(pipeline_python_class_body_sibling_base_parallel); RUN_TEST(pipeline_python_nested_helper_keeps_tests_issue1548); RUN_TEST(pipeline_python_nested_helper_keeps_tests_parallel_issue1548); RUN_TEST(pipeline_header_include_target_is_independent_of_registration_order); From 9054309fee7faa4e6c7039c243852f96b5dc3092 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 21:58:45 +0200 Subject: [PATCH 05/17] test(python): import the exc module in the class-body base fixture sqlalchemy's asyncpg adapter imports its exc module; once that import resolves, name guesses prefer exc's same-named classes over the earlier sibling of the class body. The fixture now imports exc as a module too. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index edfdc4552..be85f1f48 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15226,8 +15226,10 @@ TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548) { * exception hierarchy as nested classes, each based on an EARLIER sibling * spelled bare (`class IntegrityError(DatabaseError)`). Python evaluates a * class statement's bases in the enclosing class body, so the sibling wins - * over the same-named module classes of exc.py. A sibling defined LATER in - * the body is not bound yet: `Early(Later)` names the module-level Later. */ + * over the same-named module classes of exc.py, which the adapter imports + * as a module (so a name guess prefers them, as on sqlalchemy once its + * relative `from ... import exc` resolves). A sibling defined LATER in the + * body is not bound yet: `Early(Later)` names the module-level Later. */ static const char pyb_exc_py[] = "class DBAPIError(Exception):\n" " pass\n" "\n" @@ -15237,7 +15239,8 @@ static const char pyb_exc_py[] = "class DBAPIError(Exception):\n" "class IntegrityError(DatabaseError):\n" " pass\n"; -static const char pyb_adapter_py[] = "from exc import DBAPIError\n" +static const char pyb_adapter_py[] = "import exc\n" + "from exc import DBAPIError\n" "\n" "class Later(Exception):\n" " pass\n" From 79cb4f722792474a64cbcd5e60b797807385400f Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 22:17:23 +0200 Subject: [PATCH 06/17] fix(python): bind a nested class base to an earlier sibling of its class body Python evaluates a class statement's bases in the body that encloses it, so a bare base of a class nested in a class names an earlier sibling of that body (sqlalchemy's asyncpg adapter: class IntegrityError(DatabaseError)) before any same-named module or imported class. The base resolvers ran the registry's name strategies instead, which prefer an import-reachable candidate, so the sibling lost as soon as the adapter's exc import resolved. cbm_registry_resolve_python_class_body_base looks the sibling up exactly (., both type-like) and requires it to be defined earlier in the same file, from the graph-buffer node lines: a later sibling is not bound yet when the base is evaluated. The sequential and incremental semantic pass, the parallel resolver and the cross-LSP base resolver ask it first; a sibling needs no external-base gate. Signed-off-by: Martin Vogel --- src/pipeline/pass_lsp_cross.c | 22 +++++++++++------ src/pipeline/pass_parallel.c | 18 ++++++++------ src/pipeline/pass_semantic.c | 18 +++++++++----- src/pipeline/pipeline_internal.h | 8 +++++++ src/pipeline/registry.c | 41 ++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 20 deletions(-) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 6d99ff794..52d2b877a 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -305,10 +305,16 @@ static const char *pxc_python_dotted_base_qn(const pxc_base_scope_t *bs, const c * Returns NULL when the base is not a confidently-known project type; * stdlib and third-party bases land here and keep their raw spelling. */ static const char *pxc_resolve_base_qn(const pxc_base_scope_t *bs, const char *raw, - const char *module_qn) { + const char *module_qn, const char *class_qn) { if (!bs || !bs->reg || !raw || !raw[0]) { return NULL; } + /* A base of a class nested in a class body: an earlier sibling first. */ + const char *sibling = + cbm_registry_resolve_python_class_body_base(bs->reg, bs->gbuf, class_qn, raw, bs->lang); + if (sibling) { + return sibling; + } const char *dotted = pxc_python_dotted_base_qn(bs, raw); if (dotted) { return dotted; @@ -338,7 +344,8 @@ static const char *pxc_resolve_base_qn(const pxc_base_scope_t *bs, const char *r * source spelling. Unresolved entries pass through verbatim so a base the * registry does not know keeps working exactly as before. */ static const char *pxc_join_base_qns(CBMArena *arena, const char *const *bases, - const pxc_base_scope_t *bs, const char *module_qn) { + const pxc_base_scope_t *bs, const char *module_qn, + const char *class_qn) { if (!bases || !bases[0]) { return NULL; } @@ -352,7 +359,7 @@ static const char *pxc_join_base_qns(CBMArena *arena, const char *const *bases, return pxc_join_pipe(arena, bases); } for (int i = 0; i < count; i++) { - const char *qn = pxc_resolve_base_qn(bs, bases[i], module_qn); + const char *qn = pxc_resolve_base_qn(bs, bases[i], module_qn, class_qn); resolved[i] = qn ? qn : bases[i]; } resolved[count] = NULL; @@ -520,9 +527,10 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch /* Languages whose cross registrars read embedded_types as QNs get their * bases resolved against the project registry; everyone else keeps the * raw source spelling their own registrar already knows how to handle. */ - dst->embedded_types = (bs && bs->reg && pxc_lang_resolves_base_qns(lang)) - ? pxc_join_base_qns(arena, src->base_classes, bs, module_qn) - : pxc_join_pipe(arena, src->base_classes); + dst->embedded_types = + (bs && bs->reg && pxc_lang_resolves_base_qns(lang)) + ? pxc_join_base_qns(arena, src->base_classes, bs, module_qn, src->qualified_name) + : pxc_join_pipe(arena, src->base_classes); dst->signature_param_types = NULL; dst->signature_param_count = 0; if (src->signature_param_types && src->signature_param_count > 0) { @@ -727,7 +735,7 @@ static void pxc_fold_py_field_types(CBMArena *arena, const CBMFileResult *result fields[f].type_qn = NULL; CBMLSPDef *owner = ft->class_qn ? (CBMLSPDef *)cbm_ht_get(by_qn, ft->class_qn) : NULL; const char *name = owner ? pxc_py_annotation_type_name(arena, ft->type_text) : NULL; - const char *qn = name ? pxc_resolve_base_qn(bs, name, module_qn) : NULL; + const char *qn = name ? pxc_resolve_base_qn(bs, name, module_qn, NULL) : NULL; if (!qn || !ft->field_name || !ft->field_name[0]) { continue; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 346d4f225..f0417f207 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3863,13 +3863,17 @@ static void resolve_def_inherits(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (cbm_pipeline_php_vendor_bound(php_vendor, def->base_classes[b])) { continue; } - const char *bqn = - resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic, fs->lang); - /* Same external-base gate as the sequential semantic pass. */ - if (!bqn || - cbm_python_external_base_contradicts(fs->lang, fs->imports, def->base_classes[b], bqn, - rc->main_gbuf, rc->project_name, fs->rel)) { - continue; + /* Same order as the sequential semantic pass: an earlier sibling of + * the class body first (no external-base gate), then the registry. */ + const char *bqn = cbm_registry_resolve_python_class_body_base( + rc->registry, rc->main_gbuf, def->qualified_name, def->base_classes[b], fs->lang); + if (!bqn) { + bqn = resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic, fs->lang); + if (!bqn || cbm_python_external_base_contradicts( + fs->lang, fs->imports, def->base_classes[b], bqn, rc->main_gbuf, + rc->project_name, fs->rel)) { + continue; + } } const cbm_gbuf_node_t *bn = cbm_gbuf_find_by_qn(rc->main_gbuf, bqn); if (bn && node->id != bn->id) { diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 186b2dfdc..0c009e737 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -643,12 +643,18 @@ static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition * if (cbm_pipeline_php_vendor_bound(php_vendor, def->base_classes[b])) { continue; } - const char *base_qn = resolve_as_class(ctx->registry, def->base_classes[b], module_qn, - imp_keys, imp_vals, imp_count, lang); - if (!base_qn || cbm_python_external_base_contradicts( - fi->language, &result->imports, def->base_classes[b], base_qn, - ctx->gbuf, ctx->project_name, fi->rel_path)) { - continue; /* unresolved, or an external base (`unittest.TestCase`) */ + /* An earlier sibling of the class body shadows every module or + * imported name, so it needs no external-base gate. */ + const char *base_qn = cbm_registry_resolve_python_class_body_base( + ctx->registry, ctx->gbuf, def->qualified_name, def->base_classes[b], lang); + if (!base_qn) { + base_qn = resolve_as_class(ctx->registry, def->base_classes[b], module_qn, imp_keys, + imp_vals, imp_count, lang); + if (!base_qn || cbm_python_external_base_contradicts( + fi->language, &result->imports, def->base_classes[b], base_qn, + ctx->gbuf, ctx->project_name, fi->rel_path)) { + continue; /* unresolved, or an external base (`unittest.TestCase`) */ + } } const cbm_gbuf_node_t *base_node = cbm_gbuf_find_by_qn(ctx->gbuf, base_qn); if (base_node && node->id != base_node->id) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 0965e0398..5e5cd9506 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -270,6 +270,14 @@ cbm_resolution_t cbm_registry_resolve_call_scoped(const cbm_registry_t *r, const const char **import_map_vals, int import_map_count, CBMLanguage lang, const char *scope_qn); +/* Python: the bare base `base` of the class `class_qn` nested in a class body + * binds the sibling class of that body (`.`) when the + * sibling is defined earlier in the same file (Python evaluates the bases in + * the class body). Exact lookups only; the registry-owned sibling QN, or NULL + * to resolve the base as before. Every base-class resolver asks this first. */ +const char *cbm_registry_resolve_python_class_body_base(const cbm_registry_t *r, + const cbm_gbuf_t *gb, const char *class_qn, + const char *base, CBMLanguage lang); #ifdef CBM_ENABLE_TEST_SEAMS void cbm_pxc_test_import_probe_fail_after(int successful_allocations); #endif diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 5dda868a4..3269b9676 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1383,6 +1383,47 @@ const char *cbm_registry_value_target(const cbm_registry_t *r, const char *qn) { return owned ? owned : qn; } +/* Python evaluates a class statement's bases in the namespace of the body + * that encloses it. For a class nested in a class, a bare base naming a class + * defined EARLIER in that same class body is that sibling + * (`class IntegrityError(DatabaseError)` in sqlalchemy's asyncpg adapter), + * whatever same-named classes the project has elsewhere. A sibling defined + * later is not bound yet when the base is evaluated. Exact lookups only. */ +const char *cbm_registry_resolve_python_class_body_base(const cbm_registry_t *r, + const cbm_gbuf_t *gb, const char *class_qn, + const char *base, CBMLanguage lang) { + if (lang != CBM_LANG_PYTHON || !r || !gb || !class_qn || !base || !base[0] || + strchr(base, '.')) { + return NULL; + } + const char *dot = strrchr(class_qn, '.'); + if (!dot || dot == class_qn) { + return NULL; + } + char sibling[CBM_SZ_1K]; + int enclosing_len = (int)(dot - class_qn); + int n = snprintf(sibling, sizeof(sibling), "%.*s", enclosing_len, class_qn); + if (n < 0 || (size_t)n >= sizeof(sibling) || + !cbm_label_is_type_like(cbm_registry_label_of(r, sibling))) { + return NULL; /* not nested in a class body (module level, or a def) */ + } + n = snprintf(sibling, sizeof(sibling), "%.*s.%s", enclosing_len, class_qn, base); + if (n < 0 || (size_t)n >= sizeof(sibling) || strcmp(sibling, class_qn) == 0) { + return NULL; + } + const char *owned = cbm_ht_get_key(r->exact, sibling); + if (!owned || !cbm_label_is_type_like(cbm_registry_label_of(r, owned))) { + return NULL; + } + const cbm_gbuf_node_t *cls = cbm_gbuf_find_by_qn(gb, class_qn); + const cbm_gbuf_node_t *sib = cbm_gbuf_find_by_qn(gb, owned); + if (!cls || !sib || !cls->file_path || !sib->file_path || + strcmp(cls->file_path, sib->file_path) != 0 || sib->start_line >= cls->start_line) { + return NULL; + } + return owned; +} + int cbm_registry_find_by_name(const cbm_registry_t *r, const char *name, const char ***out, int *count) { if (!r || !out || !count) { From 29e7832c04c80771042463f60ad0565fa8453361 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 22:53:00 +0200 Subject: [PATCH 07/17] test(python): super() follows the C3 method resolution order The home-assistant radarr shape: RadarrCalendarEntity(RadarrEntity, CalendarEntity) where RadarrEntity reaches the common base Entity through CoordinatorEntity and CalendarEntity overrides Entity. In C3 order a common base comes after every class deriving from it, so super().write_state() and super().__init__() reach CalendarEntity, not Entity through the first base's ancestry. Single inheritance as the control; sequential and parallel. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 110 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4f8c656ad..48561435a 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15123,6 +15123,114 @@ TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548) { return pyn_run_shadowing(52); } +/* ── super() follows the C3 method resolution order ──────────────── */ + +/* The home-assistant radarr shape: RadarrCalendarEntity(RadarrEntity, + * CalendarEntity), where RadarrEntity reaches the common base Entity through + * CoordinatorEntity and CalendarEntity overrides Entity's method. Python's C3 + * order is [RadarrCalendarEntity, RadarrEntity, CoordinatorEntity, + * CalendarEntity, Entity]: a common base comes after every class that + * derives from it, so super() reaches CalendarEntity, not Entity through the + * first base's ancestry. */ +static const char pym_base_py[] = "class Entity:\n" + " def __init__(self):\n" + " self.ready = False\n" + "\n" + " def write_state(self):\n" + " return 0\n" + "\n" + "class CoordinatorEntity(Entity):\n" + " pass\n" + "\n" + "class CalendarEntity(Entity):\n" + " def __init__(self):\n" + " self.events = []\n" + "\n" + " def write_state(self):\n" + " return 1\n"; + +static const char pym_radarr_py[] = "from base import CoordinatorEntity, CalendarEntity\n" + "\n" + "class RadarrEntity(CoordinatorEntity):\n" + " pass\n" + "\n" + "class RadarrCalendarEntity(RadarrEntity, CalendarEntity):\n" + " def __init__(self):\n" + " super().__init__()\n" + "\n" + " def write_state(self):\n" + " return super().write_state()\n" + "\n" + "class PlainCalendar(CalendarEntity):\n" + " def write_state(self):\n" + " return super().write_state()\n"; + +static int pym_run_super_c3(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_superc3_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "base.py", pym_base_py); + write_temp_file(tmp, "radarr.py", pym_radarr_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/superc3.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + const char *pr = p ? cbm_pipeline_project_name(p) : NULL; + const char *rce = "radarr.RadarrCalendarEntity"; + char src[256]; + snprintf(src, sizeof(src), "%s.write_state", rce); + bool m_c3 = s && pyn_edge(s, pr, src, "base.CalendarEntity.write_state", "CALLS"); + bool m_dfs = s && pyn_edge(s, pr, src, "base.Entity.write_state", "CALLS"); + snprintf(src, sizeof(src), "%s.__init__", rce); + bool i_c3 = s && pyn_edge(s, pr, src, "base.CalendarEntity.__init__", "CALLS"); + bool i_dfs = s && pyn_edge(s, pr, src, "base.Entity.__init__", "CALLS"); + bool plain = s && pyn_edge(s, pr, "radarr.PlainCalendar.write_state", + "base.CalendarEntity.write_state", "CALLS"); + if (s) { + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(m_c3); /* super().write_state(): CalendarEntity comes before Entity */ + ASSERT_FALSE(m_dfs); /* ... never Entity through RadarrEntity's ancestry */ + ASSERT_TRUE(i_c3); /* super().__init__(): the same order */ + ASSERT_FALSE(i_dfs); + ASSERT_TRUE(plain); /* control: single inheritance */ + PASS(); +} + +TEST(pipeline_python_super_follows_c3_mro) { + return pym_run_super_c3(0); +} + +TEST(pipeline_python_super_follows_c3_mro_parallel) { + return pym_run_super_c3(52); +} + /* L7 review of #1548: a package __init__.py function can share a submodule's QN * (poetry: layouts/__init__.py `def layout` is the QN prefix of every symbol in * layouts/layout.py). The function-local detection is bounded at the symbol's @@ -24824,6 +24932,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_nested_def_local_after_incremental_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); + RUN_TEST(pipeline_python_super_follows_c3_mro); + RUN_TEST(pipeline_python_super_follows_c3_mro_parallel); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_issue1548); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548); RUN_TEST(pipeline_python_nested_helper_keeps_tests_issue1548); From 7fa5f89374e366d6972fcdbb8085bafab43f21b8 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 22:57:12 +0200 Subject: [PATCH 08/17] fix(python-lsp): resolve super() through the C3 method resolution order super() binds to the enclosing class's method resolution order, Python's C3 linearization, in which a common base follows every class deriving from it. The super() resolver walked the bases one by one, each through its whole ancestry, so with RadarrCalendarEntity(RadarrEntity, CalendarEntity) the first base reached Entity through CoordinatorEntity before CalendarEntity, which overrides it, was ever looked at. py_c3_linearize computes the order (memoized per lookup, capped in depth and length; -1 when the hierarchy has none), and super() takes the first class after the enclosing one that declares the member. A hierarchy without a C3 order keeps the base-by-base walk, as does the synthesized __init__ of an undeclared constructor. Signed-off-by: Martin Vogel --- internal/cbm/lsp/py_lsp.c | 148 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 18384d82e..6be9ad565 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -1229,6 +1229,138 @@ static const CBMRegisteredFunc *py_lookup_attribute(PyLSPContext *ctx, const cha return py_lookup_attribute_depth(ctx, type_qn, member_name, 0); } +/* ── super(): the C3 method resolution order ──────────────────────── */ + +/* Longest linearization followed. A longer one (or more bases than this) keeps + * the base-by-base walk of the caller. */ +#define PY_C3_MAX 64 + +/* The linearizations computed during one super() lookup: every class of a + * diamond is linearized once, however many paths reach it. */ +typedef struct { + const char *qn[PY_C3_MAX]; + const char **mro[PY_C3_MAX]; + int len[PY_C3_MAX]; + int count; +} py_c3_cache_t; + +static bool py_c3_in_tail(const char *qn, const char *const *seq, int head, int len) { + for (int k = head + 1; k < len; k++) { + if (strcmp(seq[k], qn) == 0) + return true; + } + return false; +} + +/* The C3 head to take next: the first sequence head that is in no sequence's + * tail. NULL when every head is blocked (Python rejects such a class). */ +static const char *py_c3_pick(const char **const *seqs, const int *lens, const int *heads, + int nseq) { + for (int s = 0; s < nseq; s++) { + if (heads[s] >= lens[s]) + continue; + const char *cand = seqs[s][heads[s]]; + bool blocked = false; + for (int t = 0; t < nseq && !blocked; t++) { + blocked = heads[t] < lens[t] && py_c3_in_tail(cand, seqs[t], heads[t], lens[t]); + } + if (!blocked) + return cand; + } + return NULL; +} + +/* Python's method resolution order of `type_qn`: the class, then the C3 merge + * of its bases' linearizations and of the base list. A common base therefore + * follows every class deriving from it. Sets *out to an arena array and returns + * its length, or -1 when there is no C3 order, the hierarchy is deeper than + * CBM_LSP_MAX_LOOKUP_DEPTH, or the order is longer than PY_C3_MAX. A base the + * registry does not know (external, unresolved) linearizes to itself. */ +static int py_c3_linearize(PyLSPContext *ctx, py_c3_cache_t *cache, const char *type_qn, + const char ***out, int depth) { + for (int c = 0; c < cache->count; c++) { + if (strcmp(cache->qn[c], type_qn) == 0) { + *out = cache->mro[c]; + return cache->len[c]; + } + } + if (depth > CBM_LSP_MAX_LOOKUP_DEPTH) + return -1; + const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, type_qn); + if (rt && rt->alias_of) + return py_c3_linearize(ctx, cache, rt->alias_of, out, depth + 1); + const char **bases = rt ? rt->embedded_types : NULL; + int nb = 0; + while (bases && bases[nb]) + nb++; + if (nb >= PY_C3_MAX) + return -1; + const char **seqs[PY_C3_MAX]; + int lens[PY_C3_MAX]; + int heads[PY_C3_MAX]; + for (int b = 0; b < nb; b++) { + lens[b] = py_c3_linearize(ctx, cache, bases[b], &seqs[b], depth + 1); + if (lens[b] < 0) + return -1; + heads[b] = 0; + } + seqs[nb] = bases; + lens[nb] = nb; + heads[nb] = 0; + const char **mro = (const char **)cbm_arena_alloc(ctx->arena, PY_C3_MAX * sizeof(char *)); + if (!mro) + return -1; + int n = 0; + mro[n++] = type_qn; + for (;;) { + bool pending = false; + for (int s = 0; s <= nb && !pending; s++) { + pending = heads[s] < lens[s]; + } + if (!pending) + break; + const char *pick = py_c3_pick((const char **const *)seqs, lens, heads, nb + 1); + if (!pick || n >= PY_C3_MAX) + return -1; + mro[n++] = pick; + for (int s = 0; s <= nb; s++) { + if (heads[s] < lens[s] && strcmp(seqs[s][heads[s]], pick) == 0) + heads[s]++; + } + } + if (cache->count < PY_C3_MAX) { + cache->qn[cache->count] = type_qn; + cache->mro[cache->count] = mro; + cache->len[cache->count] = n; + cache->count++; + } + *out = mro; + return n; +} + +/* The method super() reaches from inside `class_qn`: the first class AFTER it in + * its C3 order that declares `member` itself. *have_mro is false when the + * hierarchy has no C3 order; the caller then keeps its base-by-base walk. */ +static const CBMRegisteredFunc *py_lookup_super_attribute(PyLSPContext *ctx, const char *class_qn, + const char *member, bool *have_mro) { + *have_mro = false; + if (!ctx || !class_qn || !member) + return NULL; + py_c3_cache_t cache; + cache.count = 0; + const char **mro = NULL; + int n = py_c3_linearize(ctx, &cache, class_qn, &mro, 0); + if (n < 0) + return NULL; + *have_mro = true; + for (int i = 1; i < n; i++) { + const CBMRegisteredFunc *f = cbm_registry_lookup_method(ctx->registry, mro[i], member); + if (f) + return f; + } + return NULL; +} + /* Per-file field overlay: look up a (class_qn, field_name) recorded during resolve * against the sealed shared registry. Linear scan — the overlay only holds fields * discovered in THIS file's own classes, so it is small. */ @@ -2964,6 +3096,22 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { const CBMRegisteredType *enclosing = cbm_registry_lookup_type(ctx->registry, ctx->enclosing_class_qn); if (enclosing && enclosing->embedded_types) { + // The proxy walks the enclosing class's C3 order, in which a + // diamond's shared base follows every class deriving from it: + // RadarrCalendarEntity(RadarrEntity, CalendarEntity) reaches + // CalendarEntity before Entity, which the first base reaches + // through its own ancestry. The base-by-base walk below stays for + // a hierarchy without a C3 order and for an undeclared __init__. + bool have_mro = false; + const CBMRegisteredFunc *sf = py_lookup_super_attribute( + ctx, ctx->enclosing_class_qn, attr_name, &have_mro); + if (sf) { + bool is_init = strcmp(attr_name, "__init__") == 0; + py_emit_resolved_call(ctx, sf->qualified_name, + is_init ? "lsp_super_init" : "lsp_super", + is_init ? 0.90f : 0.88f, call_node); + return; + } for (int i = 0; enclosing->embedded_types[i]; i++) { // super().__init__() is a constructor delegation: // lsp_super_init is the MORE SPECIFIC, more accurate From edaefefadf591044db9dd7a533b06d2bb43ac74e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 23:23:17 +0200 Subject: [PATCH 09/17] test(python): attribute lookup follows the C3 method resolution order The radarr diamond seen from an ordinary attribute: self.write_state() in RadarrCalendarEntity and e.write_state() on a RadarrCalendarEntity look the method up along the class's C3 order, which reaches CalendarEntity before the shared base Entity. Single inheritance as the control; sequential and parallel. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 88 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 48561435a..413ea69df 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15231,6 +15231,92 @@ TEST(pipeline_python_super_follows_c3_mro_parallel) { return pym_run_super_c3(52); } +/* ── attribute lookup follows the C3 method resolution order ─────── */ + +/* The same diamond as the super() test, seen from an ordinary attribute: + * `self.write_state()` in RadarrCalendarEntity and `e.write_state()` on a + * RadarrCalendarEntity both look the method up along the class's C3 order, + * which reaches CalendarEntity before the shared base Entity. */ +static const char pya_radarr_py[] = "from base import CoordinatorEntity, CalendarEntity\n" + "\n" + "class RadarrEntity(CoordinatorEntity):\n" + " pass\n" + "\n" + "class RadarrCalendarEntity(RadarrEntity, CalendarEntity):\n" + " def refresh(self):\n" + " return self.write_state()\n" + "\n" + "class PlainCalendar(CalendarEntity):\n" + " def refresh(self):\n" + " return self.write_state()\n" + "\n" + "def publish(e: RadarrCalendarEntity):\n" + " return e.write_state()\n"; + +static int pya_run_attr_c3(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_attrc3_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "base.py", pym_base_py); + write_temp_file(tmp, "radarr.py", pya_radarr_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/attrc3.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + const char *pr = p ? cbm_pipeline_project_name(p) : NULL; + const char *c3 = "base.CalendarEntity.write_state"; + const char *dfs = "base.Entity.write_state"; + const char *self_src = "radarr.RadarrCalendarEntity.refresh"; + bool self_c3 = s && pyn_edge(s, pr, self_src, c3, "CALLS"); + bool self_dfs = s && pyn_edge(s, pr, self_src, dfs, "CALLS"); + bool typed_c3 = s && pyn_edge(s, pr, "radarr.publish", c3, "CALLS"); + bool typed_dfs = s && pyn_edge(s, pr, "radarr.publish", dfs, "CALLS"); + bool plain = s && pyn_edge(s, pr, "radarr.PlainCalendar.refresh", c3, "CALLS"); + if (s) { + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(self_c3); /* self.write_state(): CalendarEntity before Entity */ + ASSERT_FALSE(self_dfs); /* ... never Entity through RadarrEntity's ancestry */ + ASSERT_TRUE(typed_c3); /* e.write_state() on a RadarrCalendarEntity */ + ASSERT_FALSE(typed_dfs); + ASSERT_TRUE(plain); /* control: single inheritance */ + PASS(); +} + +TEST(pipeline_python_attribute_follows_c3_mro) { + return pya_run_attr_c3(0); +} + +TEST(pipeline_python_attribute_follows_c3_mro_parallel) { + return pya_run_attr_c3(52); +} + /* L7 review of #1548: a package __init__.py function can share a submodule's QN * (poetry: layouts/__init__.py `def layout` is the QN prefix of every symbol in * layouts/layout.py). The function-local detection is bounded at the symbol's @@ -24934,6 +25020,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); RUN_TEST(pipeline_python_super_follows_c3_mro); RUN_TEST(pipeline_python_super_follows_c3_mro_parallel); + RUN_TEST(pipeline_python_attribute_follows_c3_mro); + RUN_TEST(pipeline_python_attribute_follows_c3_mro_parallel); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_issue1548); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548); RUN_TEST(pipeline_python_nested_helper_keeps_tests_issue1548); From f7c55e776f6bf9132830229135075c0d5663d42f Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 23:28:25 +0200 Subject: [PATCH 10/17] fix(python-lsp): look attributes up along the C3 method resolution order Ordinary attribute lookup walked the bases one at a time, each through its whole ancestry, so on a diamond self.m() and e.m() reached the shared base before a later base that overrides it. It now follows the class's C3 order, the order super() uses, after the class's own members; a hierarchy without a C3 order keeps the depth-first walk. Each class is linearized once per file: the orders live in a per-file, arena-allocated open-addressing cache keyed by class QN, which also replaces the per-lookup cache of the super() resolver. The registry is complete before the walk starts, so an entry never goes stale; keys and the class entry of an order are arena copies. Linearizing a class costs one merge of its bases' cached orders, so no lookup re-linearizes a deep hierarchy. Signed-off-by: Martin Vogel --- internal/cbm/lsp/py_lsp.c | 230 ++++++++++++++++++++++++++------------ internal/cbm/lsp/py_lsp.h | 15 +++ 2 files changed, 175 insertions(+), 70 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 6be9ad565..32e204739 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -1224,25 +1224,85 @@ static const CBMRegisteredFunc *py_lookup_attribute_depth(PyLSPContext *ctx, con return NULL; } -static const CBMRegisteredFunc *py_lookup_attribute(PyLSPContext *ctx, const char *type_qn, - const char *member_name) { - return py_lookup_attribute_depth(ctx, type_qn, member_name, 0); -} - -/* ── super(): the C3 method resolution order ──────────────────────── */ +/* ── The C3 method resolution order ──────────────────────────────── */ /* Longest linearization followed. A longer one (or more bases than this) keeps - * the base-by-base walk of the caller. */ + * the depth-first walk above. */ #define PY_C3_MAX 64 -/* The linearizations computed during one super() lookup: every class of a - * diamond is linearized once, however many paths reach it. */ -typedef struct { - const char *qn[PY_C3_MAX]; - const char **mro[PY_C3_MAX]; - int len[PY_C3_MAX]; - int count; -} py_c3_cache_t; +enum { PY_MRO_CACHE_INITIAL_CAP = 64 }; /* power of two (index masking) */ +enum { PY_MRO_CACHE_MAX_CAP = 1 << 20 }; /* hard stop for adversarial files */ + +static uint32_t py_mro_hash(const char *qn) { + uint32_t h = 2166136261u; /* FNV-1a */ + for (const unsigned char *p = (const unsigned char *)qn; *p; p++) { + h = (h ^ *p) * 16777619u; + } + return h; +} + +static const CBMPyMroCacheEntry *py_mro_cache_find(const PyLSPContext *ctx, const char *qn, + uint32_t h) { + if (!ctx->mro_cache || ctx->mro_cache_cap == 0) + return NULL; + uint32_t mask = (uint32_t)ctx->mro_cache_cap - 1; + uint32_t idx = h & mask; + for (int probed = 0; probed < ctx->mro_cache_cap; probed++) { + const CBMPyMroCacheEntry *e = &ctx->mro_cache[idx]; + if (!e->qn) + return NULL; + if (e->hash == h && strcmp(e->qn, qn) == 0) + return e; + idx = (idx + 1) & mask; + } + return NULL; +} + +/* Doubles the table (arena allocation: the old table is freed with the + * per-file arena). */ +static bool py_mro_cache_grow(PyLSPContext *ctx) { + if (ctx->mro_cache_cap >= PY_MRO_CACHE_MAX_CAP) + return false; + int new_cap = ctx->mro_cache_cap ? ctx->mro_cache_cap * 2 : PY_MRO_CACHE_INITIAL_CAP; + CBMPyMroCacheEntry *entries = (CBMPyMroCacheEntry *)cbm_arena_alloc( + ctx->arena, (size_t)new_cap * sizeof(CBMPyMroCacheEntry)); + if (!entries) + return false; + memset(entries, 0, (size_t)new_cap * sizeof(CBMPyMroCacheEntry)); + uint32_t mask = (uint32_t)new_cap - 1; + for (int i = 0; i < ctx->mro_cache_cap; i++) { + const CBMPyMroCacheEntry *e = &ctx->mro_cache[i]; + if (!e->qn) + continue; + uint32_t idx = e->hash & mask; + while (entries[idx].qn) + idx = (idx + 1) & mask; + entries[idx] = *e; + } + ctx->mro_cache = entries; + ctx->mro_cache_cap = new_cap; + return true; +} + +/* Records the order of `qn` (len -1: none). A table that cannot grow only + * costs a recomputation later. */ +static void py_mro_cache_put(PyLSPContext *ctx, const char *qn, uint32_t h, const char **mro, + int len) { + if ((ctx->mro_cache_count + 1) * 4 > ctx->mro_cache_cap * 3 && !py_mro_cache_grow(ctx)) + return; + const char *key = cbm_arena_strdup(ctx->arena, qn); + if (!key) + return; + uint32_t mask = (uint32_t)ctx->mro_cache_cap - 1; + uint32_t idx = h & mask; + while (ctx->mro_cache[idx].qn) + idx = (idx + 1) & mask; + ctx->mro_cache[idx].qn = key; + ctx->mro_cache[idx].hash = h; + ctx->mro_cache[idx].len = len; + ctx->mro_cache[idx].mro = mro; + ctx->mro_cache_count++; +} static bool py_c3_in_tail(const char *qn, const char *const *seq, int head, int len) { for (int k = head + 1; k < len; k++) { @@ -1270,74 +1330,106 @@ static const char *py_c3_pick(const char **const *seqs, const int *lens, const i return NULL; } -/* Python's method resolution order of `type_qn`: the class, then the C3 merge - * of its bases' linearizations and of the base list. A common base therefore - * follows every class deriving from it. Sets *out to an arena array and returns - * its length, or -1 when there is no C3 order, the hierarchy is deeper than - * CBM_LSP_MAX_LOOKUP_DEPTH, or the order is longer than PY_C3_MAX. A base the - * registry does not know (external, unresolved) linearizes to itself. */ -static int py_c3_linearize(PyLSPContext *ctx, py_c3_cache_t *cache, const char *type_qn, - const char ***out, int depth) { - for (int c = 0; c < cache->count; c++) { - if (strcmp(cache->qn[c], type_qn) == 0) { - *out = cache->mro[c]; - return cache->len[c]; - } - } - if (depth > CBM_LSP_MAX_LOOKUP_DEPTH) - return -1; - const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, type_qn); - if (rt && rt->alias_of) - return py_c3_linearize(ctx, cache, rt->alias_of, out, depth + 1); - const char **bases = rt ? rt->embedded_types : NULL; - int nb = 0; - while (bases && bases[nb]) - nb++; - if (nb >= PY_C3_MAX) - return -1; - const char **seqs[PY_C3_MAX]; - int lens[PY_C3_MAX]; - int heads[PY_C3_MAX]; - for (int b = 0; b < nb; b++) { - lens[b] = py_c3_linearize(ctx, cache, bases[b], &seqs[b], depth + 1); - if (lens[b] < 0) - return -1; - heads[b] = 0; - } - seqs[nb] = bases; - lens[nb] = nb; - heads[nb] = 0; - const char **mro = (const char **)cbm_arena_alloc(ctx->arena, PY_C3_MAX * sizeof(char *)); - if (!mro) - return -1; - int n = 0; - mro[n++] = type_qn; +/* The C3 merge of `nseq` sequences into mro[1..]: returns the order's length, + * or -1 when there is none or it is longer than PY_C3_MAX. */ +static int py_c3_merge(const char **const *seqs, const int *lens, int *heads, int nseq, + const char **mro) { + int n = 1; for (;;) { bool pending = false; - for (int s = 0; s <= nb && !pending; s++) { + for (int s = 0; s < nseq && !pending; s++) { pending = heads[s] < lens[s]; } if (!pending) - break; - const char *pick = py_c3_pick((const char **const *)seqs, lens, heads, nb + 1); + return n; + const char *pick = py_c3_pick(seqs, lens, heads, nseq); if (!pick || n >= PY_C3_MAX) return -1; mro[n++] = pick; - for (int s = 0; s <= nb; s++) { + for (int s = 0; s < nseq; s++) { if (heads[s] < lens[s] && strcmp(seqs[s][heads[s]], pick) == 0) heads[s]++; } } - if (cache->count < PY_C3_MAX) { - cache->qn[cache->count] = type_qn; - cache->mro[cache->count] = mro; - cache->len[cache->count] = n; - cache->count++; +} + +/* Python's method resolution order of `type_qn`: the class, then the C3 merge + * of its bases' linearizations and of the base list. A common base therefore + * follows every class deriving from it. Computed once per class and file + * (ctx->mro_cache). Sets *out to an arena array and returns its length, or -1 + * when there is no C3 order, the hierarchy is deeper than + * CBM_LSP_MAX_LOOKUP_DEPTH, or the order is longer than PY_C3_MAX. A base the + * registry does not know (external, unresolved) linearizes to itself. Only an + * order computed from the top (depth 0) is cached as missing: below it, the + * depth cap may have cut a hierarchy that is fine on its own. */ +static int py_c3_linearize(PyLSPContext *ctx, const char *type_qn, const char ***out, int depth) { + uint32_t h = py_mro_hash(type_qn); + const CBMPyMroCacheEntry *hit = py_mro_cache_find(ctx, type_qn, h); + if (hit) { + *out = hit->mro; + return hit->len; } - *out = mro; + int n = -1; + const char **mro = NULL; + const CBMRegisteredType *rt = + depth <= CBM_LSP_MAX_LOOKUP_DEPTH ? cbm_registry_lookup_type(ctx->registry, type_qn) : NULL; + const char **bases = rt ? rt->embedded_types : NULL; + int nb = 0; + while (bases && bases[nb]) + nb++; + if (depth > CBM_LSP_MAX_LOOKUP_DEPTH || nb >= PY_C3_MAX) { + n = -1; + } else if (rt && rt->alias_of) { + n = py_c3_linearize(ctx, rt->alias_of, &mro, depth + 1); + } else { + const char ***seqs = + (const char ***)cbm_arena_alloc(ctx->arena, (size_t)(nb + 1) * sizeof(const char **)); + int *lens = (int *)cbm_arena_alloc(ctx->arena, (size_t)(nb + 1) * 2 * sizeof(int)); + mro = (const char **)cbm_arena_alloc(ctx->arena, PY_C3_MAX * sizeof(const char *)); + bool ok = seqs && lens && mro; + int *heads = lens ? lens + nb + 1 : NULL; + for (int b = 0; ok && b < nb; b++) { + lens[b] = py_c3_linearize(ctx, bases[b], &seqs[b], depth + 1); + heads[b] = 0; + ok = lens[b] >= 0; + } + if (ok) { + seqs[nb] = bases; + lens[nb] = nb; + heads[nb] = 0; + mro[0] = cbm_arena_strdup(ctx->arena, type_qn); + n = mro[0] ? py_c3_merge((const char **const *)seqs, lens, heads, nb + 1, mro) : -1; + } + } + if (n >= 0 || depth == 0) + py_mro_cache_put(ctx, type_qn, h, n >= 0 ? mro : NULL, n); + *out = n >= 0 ? mro : NULL; return n; } +/* Attribute lookup along the class's C3 order: the class itself, then each + * class of its order that declares the member. A hierarchy without a C3 order + * keeps the depth-first walk. */ +static const CBMRegisteredFunc *py_lookup_attribute(PyLSPContext *ctx, const char *type_qn, + const char *member_name) { + if (!ctx || !type_qn || !member_name) + return NULL; + const CBMRegisteredFunc *f = cbm_registry_lookup_method(ctx->registry, type_qn, member_name); + if (f) + return f; + const char **mro = NULL; + int n = py_c3_linearize(ctx, type_qn, &mro, 0); + if (n < 0) + return py_lookup_attribute_depth(ctx, type_qn, member_name, 0); + /* mro[0] is the class itself, already asked, or an alias's target. */ + for (int i = strcmp(mro[0], type_qn) == 0 ? 1 : 0; i < n; i++) { + f = cbm_registry_lookup_method(ctx->registry, mro[i], member_name); + if (f) + return f; + } + return NULL; +} + /* The method super() reaches from inside `class_qn`: the first class AFTER it in * its C3 order that declares `member` itself. *have_mro is false when the * hierarchy has no C3 order; the caller then keeps its base-by-base walk. */ @@ -1346,10 +1438,8 @@ static const CBMRegisteredFunc *py_lookup_super_attribute(PyLSPContext *ctx, con *have_mro = false; if (!ctx || !class_qn || !member) return NULL; - py_c3_cache_t cache; - cache.count = 0; const char **mro = NULL; - int n = py_c3_linearize(ctx, &cache, class_qn, &mro, 0); + int n = py_c3_linearize(ctx, class_qn, &mro, 0); if (n < 0) return NULL; *have_mro = true; diff --git a/internal/cbm/lsp/py_lsp.h b/internal/cbm/lsp/py_lsp.h index fa5e03e06..9ac53521b 100644 --- a/internal/cbm/lsp/py_lsp.h +++ b/internal/cbm/lsp/py_lsp.h @@ -38,6 +38,16 @@ typedef struct { const CBMType *result; // full-fidelity result, never NULL in a live entry } CBMPyTypeCacheEntry; +// A class's C3 method resolution order (py_c3_linearize), computed once per +// class and file. The registry is complete before the walk starts, so an entry +// never goes stale. +typedef struct { + const char *qn; // class QN; NULL marks an empty slot + uint32_t hash; // hash of qn + int len; // entries in mro; -1 = the class has no C3 order + const char **mro; // arena-allocated, mro[0] is the class (or its alias target) +} CBMPyMroCacheEntry; + // PyLSPContext holds state for one Python file's type evaluation. typedef struct { CBMArena *arena; @@ -109,6 +119,11 @@ typedef struct { int eval_depth; // evaluator recursion depth (PY_LSP_MAX_EVAL_DEPTH) uint32_t eval_truncations; // depth-cap cutoff count — gates memo inserts CBMLSPStatus eval_failure; // sticky: explicit failure makes this walk incomplete + // C3 orders of the classes whose attributes this file looks up: one + // linearization per class, open addressing, arena-allocated. + CBMPyMroCacheEntry *mro_cache; + int mro_cache_count; // occupied slots (kept < 75% of cap) + int mro_cache_cap; // power-of-two capacity // Pending links of the left-associative chains being evaluated // iteratively (operator, receiver, subscript, conditional and // parenthesis chains): one stack shared by nested evaluations. From 257aea1379507ae287f70bf9e5e31428b2f6cb96 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 23:49:29 +0200 Subject: [PATCH 11/17] test(python): annotations resolve through TYPE_CHECKING imports (#1277) The second half of #1277: the reporter's typed-field chain with both annotation imports under `if TYPE_CHECKING:` (the usual shape when the import would cycle), through `typing.TYPE_CHECKING` too. The call resolves by the LSP as with runtime imports, and a runtime call through a TYPE_CHECKING-only name gets no CALLS edge at all. The extraction test reads the new CBMImport.type_checking_only flag, declared here and not yet set. Signed-off-by: Martin Vogel --- internal/cbm/cbm.h | 2 + tests/test_extraction.c | 45 +++++++++++++++ tests/test_pipeline.c | 118 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 86586dd15..9c736d67b 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -398,6 +398,8 @@ typedef struct { const char *member; // ES named import: the exported name the local binds (`get` in // `import { get as navigate } from "axios"`); NULL otherwise bool is_default; // ES default import (`import X from "Y"`), JS/TS only (#1916) + bool type_checking_only; // Python: inside a module-level `if TYPE_CHECKING:` block -- + // binds the name for annotations, never at runtime (#1277) CBMImportKind kind; // CBM_IMPORT_KIND_DEFAULT unless the syntax names a member kind } CBMImport; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5f46a416d..fcaa3655f 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1506,6 +1506,50 @@ TEST(python_annotated_instance_fields_exported_issue1277) { PASS(); } +/* #1277: the import an annotated field's type comes from usually sits under + * `if TYPE_CHECKING:`. It is recorded, flagged annotation-only; a runtime + * import is not flagged; an else branch (a runtime alternative) and an + * ordinary `if` stay unrecorded as before. */ +static const CBMImport *py_find_import(const CBMFileResult *r, const char *local) { + for (int i = 0; i < r->imports.count; i++) { + if (r->imports.items[i].local_name && strcmp(r->imports.items[i].local_name, local) == 0) + return &r->imports.items[i]; + } + return NULL; +} + +TEST(python_type_checking_imports_flagged_issue1277) { + const char *src = "from typing import TYPE_CHECKING\n" + "import typing\n" + "if TYPE_CHECKING:\n" + " from trainer import Trainer\n" + " import contracts\n" + "else:\n" + " from fallback import Runtime\n" + "if typing.TYPE_CHECKING:\n" + " from engine import Engine as Eng\n" + "if FLAG:\n" + " from other import Conditional\n"; + CBMFileResult *r = extract(src, CBM_LANG_PYTHON, "t", "loop.py"); + ASSERT_NOT_NULL(r); + const CBMImport *tc = py_find_import(r, "TYPE_CHECKING"); + const CBMImport *trainer = py_find_import(r, "Trainer"); + const CBMImport *contracts = py_find_import(r, "contracts"); + const CBMImport *eng = py_find_import(r, "Eng"); + ASSERT_NOT_NULL(tc); + ASSERT_FALSE(tc->type_checking_only); + ASSERT_NOT_NULL(trainer); + ASSERT_TRUE(trainer->type_checking_only); + ASSERT_NOT_NULL(contracts); + ASSERT_TRUE(contracts->type_checking_only); + ASSERT_NOT_NULL(eng); + ASSERT_TRUE(eng->type_checking_only); + ASSERT_NULL(py_find_import(r, "Runtime")); + ASSERT_NULL(py_find_import(r, "Conditional")); + cbm_free_result(r); + PASS(); +} + TEST(python_function) { CBMFileResult *r = extract( "def greet(name):\n return f\"Hello {name}\"\n\ndef main():\n greet(\"World\")\n", @@ -12625,6 +12669,7 @@ SUITE(extraction) { RUN_TEST(python_function); RUN_TEST(python_class); RUN_TEST(python_annotated_instance_fields_exported_issue1277); + RUN_TEST(python_type_checking_imports_flagged_issue1277); RUN_TEST(js_function); RUN_TEST(js_class); RUN_TEST(ts_function); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4f8c656ad..c8578430e 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -13666,8 +13666,17 @@ TEST(pipeline_python_init_import_probe_failure_falls_back) { /* #1277: typed instance fields of a Python class imported from another file. * Counts CALLS edges caller -> callee (QN suffixes) whose properties name an * LSP strategy. */ +static int count_calls_with(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, const char *fragment); + static int count_lsp_calls(cbm_store_t *s, const char *project, const char *caller_suffix, const char *callee_suffix) { + return count_calls_with(s, project, caller_suffix, callee_suffix, "lsp"); +} + +/* As count_lsp_calls, with any properties fragment (NULL = every CALLS edge). */ +static int count_calls_with(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, const char *fragment) { cbm_edge_t *edges = NULL; int edge_count = 0; cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count); @@ -13683,7 +13692,8 @@ static int count_lsp_calls(cbm_store_t *s, const char *project, const char *call size_t ts = strlen(callee_suffix); if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 && strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0 && - edges[i].properties_json && strstr(edges[i].properties_json, "lsp")) { + (!fragment || + (edges[i].properties_json && strstr(edges[i].properties_json, fragment)))) { hits++; } } @@ -13802,6 +13812,110 @@ TEST(python_crossfile_typed_field_calls_issue1277_parallel) { return check_python_crossfile_typed_field_calls_issue1277(true); } +/* #1277, second half: the reporter's chain with both annotation imports + * under `if TYPE_CHECKING:` (the usual shape when the import would cycle). + * trainer.strategies is typed through a TYPE_CHECKING import of Contract, and + * run()'s parameter through a typing.TYPE_CHECKING import of Trainer; the call + * resolves by the LSP exactly as with runtime imports. A runtime call through + * a TYPE_CHECKING-only name cannot happen: it gets no CALLS edge at all. */ +static int check_python_type_checking_typed_field_issue1277(bool parallel) { + const char *contracts = "class Contract:\n" + " def process_batch(self) -> None:\n" + " ...\n"; + const char *trainer = "from __future__ import annotations\n" + "from typing import TYPE_CHECKING\n\n" + "if TYPE_CHECKING:\n" + " from contracts import Contract\n\n" + "class Trainer:\n" + " def __init__(self, strategies: Contract) -> None:\n" + " self.strategies = strategies\n"; + const char *loop = "from __future__ import annotations\n" + "import typing\n\n" + "if typing.TYPE_CHECKING:\n" + " from trainer import Trainer\n\n" + "def run(trainer: Trainer) -> None:\n" + " trainer.strategies.process_batch()\n\n" + "def bad():\n" + " return Trainer()\n"; + if (setup_usages_repo("contracts.py", contracts, "trainer.py", trainer) != 0) { + FAIL("failed to create temp dir"); + } + char path[512]; + snprintf(path, sizeof(path), "%s/loop.py", g_usages_tmpdir); + if (th_write_file(path, loop) != 0) { + teardown_usages_repo(); + FAIL("failed to write loop.py"); + } + if (parallel) { + for (int i = 0; i < 50; i++) { + snprintf(path, sizeof(path), "%s/tc_pad_%02d.py", g_usages_tmpdir, i); + if (th_write_file(path, "tc_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_1277_tc.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 via_field = -1, runtime_import = -1, runtime_lsp = -1; + if (s) { + const char *project = cbm_pipeline_project_name(p); + via_field = count_lsp_calls(s, project, "loop.run", "contracts.Contract.process_batch"); + runtime_import = count_calls_with(s, project, "loop.bad", "trainer.Trainer", + "\"strategy\":\"import_map"); + runtime_lsp = count_lsp_calls(s, project, "loop.bad", "trainer.Trainer"); + 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(via_field, 1); /* annotation through TYPE_CHECKING imports */ + ASSERT_EQ(runtime_import, 0); /* control: no CALLS import map binds the name */ + ASSERT_EQ(runtime_lsp, 0); /* control: the LSP binds it for annotations only */ + PASS(); +} + +TEST(python_type_checking_typed_field_issue1277) { + return check_python_type_checking_typed_field_issue1277(false); +} + +TEST(python_type_checking_typed_field_issue1277_parallel) { + return check_python_type_checking_typed_field_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 @@ -24798,6 +24912,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_type_checking_typed_field_issue1277); + RUN_TEST(python_type_checking_typed_field_issue1277_parallel); RUN_TEST(python_dotted_base_inheritance); RUN_TEST(python_dotted_base_inheritance_parallel); RUN_TEST(python_typed_field_external_import_stays_unresolved); From 3a46d69212d8deb59d4514385f6dc67bb4d614ab Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 23:55:41 +0200 Subject: [PATCH 12/17] fix(python): resolve annotations through TYPE_CHECKING imports (#1277) The import an annotated field's type comes from usually sits under a module-level `if TYPE_CHECKING:` (to avoid an import cycle). parse_python_imports walked only the module's direct statements, so that import was never recorded and the reporter's chain trainer.strategies.process_batch() lost its type exactly where real code writes it. - Extraction records the imports of a module-level `if TYPE_CHECKING:` / `if .TYPE_CHECKING:` consequence block, flagged CBMImport.type_checking_only. elif/else branches stay unrecorded, as every other compound statement's imports do. - One rule for every CALLS import-map builder: cbm_import_binds_runtime_name() (false for a flagged import). The sequential build_import_map applies it in its fast and slow paths; the shared cbm_pxc_build_import_map (= _ex(..., false)) applies it in both loops, so no CALLS import map ever binds an annotation-only name. - The annotation consumers take cbm_pxc_build_import_map_ex(..., true): the cross-file base/field-type resolution and the Python LSP dispatch. The sequential dispatch always takes it; the parallel one builds that map only for a file that records a flagged import and otherwise passes its CALLS map, which is identical there by construction. - The Python LSP binds those names into an annotation-only frame consulted by annotation resolution after the ordinary scope chain: a runtime call through such a name never resolves. Tests: python_type_checking_imports_flagged_issue1277 (extraction), python_type_checking_typed_field_issue1277(_parallel) (pipeline, both venues; controls: no import_map and no LSP CALLS edge through the name). Signed-off-by: Martin Vogel --- internal/cbm/cbm.c | 18 +++++++++ internal/cbm/cbm.h | 13 +++++++ internal/cbm/extract_imports.c | 70 +++++++++++++++++++++++++++++----- internal/cbm/lsp/py_lsp.c | 49 ++++++++++++++++++++++++ internal/cbm/lsp/py_lsp.h | 4 ++ src/pipeline/pass_calls.c | 7 +++- src/pipeline/pass_lsp_cross.c | 38 +++++++++++++++--- src/pipeline/pass_lsp_cross.h | 11 ++++++ src/pipeline/pass_parallel.c | 21 +++++++++- 9 files changed, 213 insertions(+), 18 deletions(-) diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index a1a7b7c72..a69bd1b8a 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -402,6 +402,24 @@ void cbm_imports_push(CBMImportArray *arr, CBMArena *a, CBMImport imp) { arr->items[arr->count++] = imp; } +bool cbm_imports_local_binds_runtime_name(const CBMImportArray *imports, const char *local_name) { + if (!imports || !local_name) { + return true; + } + bool seen = false; + for (int i = 0; i < imports->count; i++) { + const CBMImport *imp = &imports->items[i]; + if (!imp->local_name || strcmp(imp->local_name, local_name) != 0) { + continue; + } + if (cbm_import_binds_runtime_name(imp)) { + return true; + } + seen = true; + } + return !seen; +} + void cbm_usages_push(CBMUsageArray *arr, CBMArena *a, CBMUsage usage) { GROW_ARRAY(arr, a); arr->items[arr->count++] = usage; diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 9c736d67b..bb9799a15 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -403,6 +403,15 @@ typedef struct { CBMImportKind kind; // CBM_IMPORT_KIND_DEFAULT unless the syntax names a member kind } CBMImport; +/* True when an import binds its local name at runtime. A Python import under + * `if TYPE_CHECKING:` exists for annotations only: a call through that name + * cannot happen, so no CALLS import map may bind it (an import_map edge + * through it would be fabricated by construction). THE one rule every CALLS + * import-map builder applies. */ +static inline bool cbm_import_binds_runtime_name(const CBMImport *imp) { + return imp && !imp->type_checking_only; +} + /* Content-only ordered Python namespace facts; no resolved target identities. */ #define CBM_PY_NAMESPACE_FACTS_VERSION 1u @@ -1308,6 +1317,10 @@ void cbm_py_namespace_test_copy_fail_after(int successful_allocations); void cbm_defs_push(CBMDefArray *arr, CBMArena *a, CBMDefinition def); void cbm_calls_push(CBMCallArray *arr, CBMArena *a, CBMCall call); void cbm_imports_push(CBMImportArray *arr, CBMArena *a, CBMImport imp); +/* True unless every import of `imports` that binds `local_name` is + * annotation-only (cbm_import_binds_runtime_name), so also true when no import + * names it: the rule for the builders that key IMPORTS edges by local name. */ +bool cbm_imports_local_binds_runtime_name(const CBMImportArray *imports, const char *local_name); void cbm_usages_push(CBMUsageArray *arr, CBMArena *a, CBMUsage usage); void cbm_throws_push(CBMThrowArray *arr, CBMArena *a, CBMThrow thr); void cbm_rw_push(CBMRWArray *arr, CBMArena *a, CBMReadWrite rw); diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 576bdb8f1..efce3361b 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -321,6 +321,63 @@ static void process_py_import_from(CBMExtractCtx *ctx, TSNode node) { } } +/* `TYPE_CHECKING` or `.TYPE_CHECKING` (typing.TYPE_CHECKING, t.TYPE_CHECKING). */ +static bool py_is_type_checking_condition(CBMExtractCtx *ctx, TSNode cond) { + if (ts_node_is_null(cond)) { + return false; + } + TSNode name = cond; + if (strcmp(ts_node_type(cond), "attribute") == 0) { + name = ts_node_child_by_field_name(cond, TS_FIELD("attribute")); + } + if (ts_node_is_null(name) || strcmp(ts_node_type(name), "identifier") != 0) { + return false; + } + const char *text = cbm_node_text(ctx->arena, name, ctx->source); + return text && strcmp(text, "TYPE_CHECKING") == 0; +} + +/* One import statement; `type_only` flags every import it records. */ +static void process_py_import_node(CBMExtractCtx *ctx, TSNode node, bool type_only) { + const char *kind = ts_node_type(node); + int before = ctx->result->imports.count; + if (strcmp(kind, "import_statement") == 0) { + process_py_import_stmt(ctx, node); + } else if (strcmp(kind, "import_from_statement") == 0 || + strcmp(kind, "future_import_statement") == 0) { + // `from __future__ import annotations` is a distinct node type in + // tree-sitter-python but has the same shape (module + name list). + process_py_import_from(ctx, node); + } + for (int i = before; type_only && i < ctx->result->imports.count; i++) { + ctx->result->imports.items[i].type_checking_only = true; + } +} + +/* A module-level `if TYPE_CHECKING:` holds imports a type checker sees and + * the interpreter never runs (#1277: an annotated field's type is usually + * imported exactly there, to avoid an import cycle). Record the imports of + * its consequence block, flagged type_checking_only; its elif/else branches + * are runtime alternatives and stay unrecorded, as every other compound + * statement's imports do. */ +static void process_py_type_checking_block(CBMExtractCtx *ctx, TSNode if_node) { + if (!py_is_type_checking_condition( + ctx, ts_node_child_by_field_name(if_node, TS_FIELD("condition")))) { + return; + } + TSNode body = ts_node_child_by_field_name(if_node, TS_FIELD("consequence")); + if (ts_node_is_null(body)) { + return; + } + TSTreeCursor cursor = ts_tree_cursor_new(body); + if (ts_tree_cursor_goto_first_child(&cursor)) { + do { + process_py_import_node(ctx, ts_tree_cursor_current_node(&cursor), true); + } while (ts_tree_cursor_goto_next_sibling(&cursor)); + } + ts_tree_cursor_delete(&cursor); +} + static void parse_python_imports(CBMExtractCtx *ctx) { TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); if (!ts_tree_cursor_goto_first_child(&cursor)) { @@ -329,15 +386,10 @@ static void parse_python_imports(CBMExtractCtx *ctx) { } do { TSNode node = ts_tree_cursor_current_node(&cursor); - const char *kind = ts_node_type(node); - - if (strcmp(kind, "import_statement") == 0) { - process_py_import_stmt(ctx, node); - } else if (strcmp(kind, "import_from_statement") == 0 || - strcmp(kind, "future_import_statement") == 0) { - // `from __future__ import annotations` is a distinct node type in - // tree-sitter-python but has the same shape (module + name list). - process_py_import_from(ctx, node); + if (strcmp(ts_node_type(node), "if_statement") == 0) { + process_py_type_checking_block(ctx, node); + } else { + process_py_import_node(ctx, node, false); } } while (ts_tree_cursor_goto_next_sibling(&cursor)); ts_tree_cursor_delete(&cursor); diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 18384d82e..a9641fe6b 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -4278,6 +4278,11 @@ static const CBMType *py_resolve_annotation(PyLSPContext *ctx, const char *ann) const CBMType *t = cbm_scope_lookup(ctx->current_scope, ann); if (!cbm_type_is_unknown(t)) return t; + if (ctx->annotation_scope) { + t = cbm_scope_lookup(ctx->annotation_scope, ann); + if (!cbm_type_is_unknown(t)) + return t; + } if (ctx->module_qn) { const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, ann); const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, qn); @@ -5360,6 +5365,27 @@ static void py_replay_import_statement(PyLSPContext *ctx, TSNode stmt, unsigned } } +/* The consequence block of a module-level `if TYPE_CHECKING:` / + * `if .TYPE_CHECKING:` statement, else a null node. Mirrors the + * extraction's py_is_type_checking_condition (extract_imports.c). */ +static TSNode py_type_checking_body(PyLSPContext *ctx, TSNode stmt) { + TSNode none = {0}; + if (strcmp(ts_node_type(stmt), "if_statement") != 0) + return none; + TSNode cond = ts_node_child_by_field_name(stmt, "condition", 9); + if (ts_node_is_null(cond)) + return none; + TSNode name = cond; + if (strcmp(ts_node_type(cond), "attribute") == 0) + name = ts_node_child_by_field_name(cond, "attribute", 9); + if (ts_node_is_null(name) || strcmp(ts_node_type(name), "identifier") != 0) + return none; + char *text = py_node_text(ctx, name); + if (!text || strcmp(text, "TYPE_CHECKING") != 0) + return none; + return ts_node_child_by_field_name(stmt, "consequence", 11); +} + static bool py_expression_is_annotation_only_assignment(TSNode statement) { if (ts_node_named_child_count(statement) != 1) return false; @@ -5444,6 +5470,29 @@ void py_lsp_process_file(PyLSPContext *ctx, TSNode root) { * Invalidate only syntactic binding targets at the control-flow * join; a later unconditional statement may restore exact proof. */ py_invalidate_possible_bindings(ctx, c, 0); + /* `if TYPE_CHECKING:` holds the imports annotations are written + * against (#1277). Bind them into the annotation-only frame: an + * annotation resolves through them, a runtime call never does (the + * interpreter never runs the block). Extraction recorded them + * flagged type_checking_only, so they reach this walk through the + * LSP import map only, never a CALLS import map. */ + TSNode tc_body = py_type_checking_body(ctx, c); + if (!ts_node_is_null(tc_body)) { + if (!ctx->annotation_scope) + ctx->annotation_scope = cbm_scope_push(ctx->arena, NULL); + CBMScope *runtime_scope = ctx->current_scope; + PyKids tk = py_kids(ctx->arena, tc_body); + for (uint32_t t = 0; ctx->annotation_scope && t < tk.n && !ctx->eval_failure; t++) { + TSNode stmt = py_kid(&tk, t); + const char *sk = ts_node_type(stmt); + if (strcmp(sk, "import_statement") == 0 || + strcmp(sk, "import_from_statement") == 0) { + ctx->current_scope = ctx->annotation_scope; + py_replay_import_statement(ctx, stmt, consumed_imports); + ctx->current_scope = runtime_scope; + } + } + } } } // Pass 2: top-level calls (rare) and nested definitions. diff --git a/internal/cbm/lsp/py_lsp.h b/internal/cbm/lsp/py_lsp.h index fa5e03e06..a838f47c5 100644 --- a/internal/cbm/lsp/py_lsp.h +++ b/internal/cbm/lsp/py_lsp.h @@ -49,6 +49,10 @@ typedef struct { * cbm_registry_*_for_update, never into anything behind ->fallback. */ CBMTypeRegistry *registry_head; CBMScope *current_scope; + // Names a module-level `if TYPE_CHECKING:` imports (#1277): consulted by + // annotation resolution only, after the ordinary scope chain misses, so a + // runtime call through such a name never resolves. NULL when none. + CBMScope *annotation_scope; // Import map: local_name -> module_qn (arena-allocated, NULL-terminated). // Built from CBMFileResult.imports. diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index dfbb8f7fb..9d60ee5c9 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -141,7 +141,8 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, for (int i = 0; i < result->imports.count; i++) { const CBMImport *imp = &result->imports.items[i]; - if (!imp->local_name || !imp->local_name[0] || !imp->module_path) { + if (!imp->local_name || !imp->local_name[0] || !imp->module_path || + !cbm_import_binds_runtime_name(imp)) { continue; } const cbm_gbuf_node_t *target = @@ -193,6 +194,10 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, continue; } char *key = extract_local_name_from_json(e->properties_json); + if (key && !cbm_imports_local_binds_runtime_name(result ? &result->imports : NULL, key)) { + free(key); /* annotation-only import (#1277): no CALLS binding */ + key = NULL; + } if (key) { keys[count] = key; vals[count] = target->qualified_name; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 6d99ff794..b1fa5f264 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -876,9 +876,11 @@ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMArena *are if (ctx && ctx->registry && pxc_lang_resolves_base_qns(files[fi].language)) { bs.reg = ctx->registry; bs.gbuf = ctx->gbuf; - cbm_pxc_build_import_map(ctx->gbuf, project_name, files[fi].rel_path, - files[fi].language, fr, &bs.imp_keys, &bs.imp_vals, - &bs.imp_count); + /* Bases and field annotations resolve through annotation-only + * imports too: `if TYPE_CHECKING:` exists for them (#1277). */ + cbm_pxc_build_import_map_ex(ctx->gbuf, project_name, files[fi].rel_path, + files[fi].language, fr, true, &bs.imp_keys, &bs.imp_vals, + &bs.imp_count); } for (int di = 0; di < fr->defs.count; di++) { if (pxc_build_lsp_def(arena, &fr->defs.items[di], def_modules[fi], namespace_name, @@ -1068,10 +1070,26 @@ static char *pxc_python_import_from_metadata(const cbm_gbuf_t *gbuf, const char /* Build per-file import map (local_name -> semantic import QN) from gbuf * IMPORTS edges. Both pipeline drivers call this implementation. Returns 0 - * with *out_count = 0 when the file has no IMPORTS edges. */ + * with *out_count = 0 when the file has no IMPORTS edges. The CALLS map. */ int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, const char *rel_path, CBMLanguage lang, const CBMFileResult *result, const char ***out_keys, const char ***out_vals, int *out_count) { + return cbm_pxc_build_import_map_ex(gbuf, project_name, rel_path, lang, result, false, out_keys, + out_vals, out_count); +} + +bool cbm_pxc_result_has_type_only_import(const CBMFileResult *result) { + for (int i = 0; result && i < result->imports.count; i++) { + if (!cbm_import_binds_runtime_name(&result->imports.items[i])) + return true; + } + return false; +} + +int cbm_pxc_build_import_map_ex(const cbm_gbuf_t *gbuf, const char *project_name, + const char *rel_path, CBMLanguage lang, const CBMFileResult *result, + bool include_type_only, const char ***out_keys, + const char ***out_vals, int *out_count) { *out_keys = NULL; *out_vals = NULL; *out_count = 0; @@ -1129,6 +1147,11 @@ int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, c continue; memcpy(local, start, n); local[n] = '\0'; + if (!include_type_only && + !cbm_imports_local_binds_runtime_name(result ? &result->imports : NULL, local)) { + free(local); /* annotation-only import (#1277): no CALLS binding */ + continue; + } char *value = pxc_import_value_qn(lang, result, local, target); if (!value) { free(local); @@ -1146,6 +1169,7 @@ int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, c for (int i = 0; i < metadata_count; i++) { const CBMImport *imp = &result->imports.items[i]; if (!imp->local_name || !imp->local_name[0] || !imp->module_path || + (!include_type_only && !cbm_import_binds_runtime_name(imp)) || pxc_import_map_has_local(keys, count, imp->local_name)) { continue; } @@ -2212,8 +2236,10 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * const char **imp_keys = NULL; const char **imp_vals = NULL; int imp_count = 0; - cbm_pxc_build_import_map(ctx->gbuf, ctx->project_name, files[i].rel_path, lang, cache[i], - &imp_keys, &imp_vals, &imp_count); + /* The LSP map: annotation-only imports bind for annotations (#1277). + * MUST match the LSP map of pass_parallel.c. */ + cbm_pxc_build_import_map_ex(ctx->gbuf, ctx->project_name, files[i].rel_path, lang, cache[i], + true, &imp_keys, &imp_vals, &imp_count); /* Journal around the resolve: a hang here must be attributed to THIS * file, not to a stale extraction marker (the innocent-quarantine diff --git a/src/pipeline/pass_lsp_cross.h b/src/pipeline/pass_lsp_cross.h index 05e1545ca..bcbe30425 100644 --- a/src/pipeline/pass_lsp_cross.h +++ b/src/pipeline/pass_lsp_cross.h @@ -83,6 +83,17 @@ void cbm_pxc_ts_modes(CBMLanguage lang, const char *rel_path, bool *out_js, bool int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, const char *rel_path, CBMLanguage lang, const CBMFileResult *result, const char ***out_keys, const char ***out_vals, int *out_count); +/* As cbm_pxc_build_import_map (which is include_type_only = false, the CALLS + * map), but with include_type_only also binding annotation-only imports + * (Python `if TYPE_CHECKING:`), for the consumers that resolve annotations: + * the Python LSP and the cross-file base/field-type resolution (#1277). */ +int cbm_pxc_build_import_map_ex(const cbm_gbuf_t *gbuf, const char *project_name, + const char *rel_path, CBMLanguage lang, const CBMFileResult *result, + bool include_type_only, const char ***out_keys, + const char ***out_vals, int *out_count); +/* True when the file records an annotation-only import (the LSP map then + * differs from the CALLS map and must be built separately). */ +bool cbm_pxc_result_has_type_only_import(const CBMFileResult *result); void cbm_pxc_free_import_map(const char **keys, const char **vals, int count); diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 346d4f225..1e24b0261 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -4323,10 +4323,27 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { * this file, not to a stale extraction marker. */ cbm_index_mark_start(rel); bool had_error = result->has_error; + /* The LSP map also binds annotation-only imports (#1277); + * only a file that records one needs its own map, every + * other file passes the CALLS map unchanged. MUST match the + * sequential dispatch in pass_lsp_cross.c. */ + const char **lsp_keys = NULL; + const char **lsp_vals = NULL; + int lsp_count = 0; + bool own_lsp_map = cbm_pxc_result_has_type_only_import(result); + if (own_lsp_map) { + cbm_pxc_build_import_map_ex(rc->main_gbuf, rc->project_name, rel, lang, result, + true, &lsp_keys, &lsp_vals, &lsp_count); + } cbm_pxc_dispatch_file_with_php_bindings( lang, result, lsp_source, lsp_source_len, rel, def_module, rc->cross_registries, - rc->module_def_index, rc->all_defs, rc->def_count, imp_keys, imp_vals, - imp_count, pp_rust_shared_registry_get, rc, &php_vendor); + rc->module_def_index, rc->all_defs, rc->def_count, + own_lsp_map ? lsp_keys : imp_keys, own_lsp_map ? lsp_vals : imp_vals, + own_lsp_map ? lsp_count : imp_count, pp_rust_shared_registry_get, rc, + &php_vendor); + if (own_lsp_map) { + cbm_pxc_free_import_map(lsp_keys, lsp_vals, lsp_count); + } cbm_index_mark_done(rel); if (!had_error && result->has_error && result->error_msg && (strcmp(result->error_msg, CBM_C_LSP_MEMO_ERROR) == 0 || From a45f36251e79ad142811f03a60c89e1d8eb1b04d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 00:20:06 +0200 Subject: [PATCH 13/17] test(python): a name the else branch rebinds is no TYPE_CHECKING-only import (#1277) The airflow compat shape imports a name under `if TYPE_CHECKING:` and again in the else branch, usually inside try/except ImportError: the name is bound at runtime, so its TYPE_CHECKING import is not annotation-only and must stay unrecorded, as the runtime import itself does. A star import in an elif or else branch may bind any name. A pure annotation import stays flagged. Signed-off-by: Martin Vogel --- tests/test_extraction.c | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index fcaa3655f..5d0165252 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1550,6 +1550,38 @@ TEST(python_type_checking_imports_flagged_issue1277) { PASS(); } +/* A name the alternative of an `if TYPE_CHECKING:` binds again (the airflow + * compat shape, usually inside try/except ImportError) is bound at runtime, so + * its TYPE_CHECKING import is not annotation-only: it stays unrecorded, as the + * runtime import itself does. A star import in an alternative may bind any + * name, so it covers every name of the block. */ +TEST(python_type_checking_rebound_in_else_unrecorded_issue1277) { + const char *src = "from typing import TYPE_CHECKING\n" + "if TYPE_CHECKING:\n" + " from airflow.sdk.execution_time.secrets_masker import mask_secret\n" + " from airflow.sdk.types import OnlyTyped\n" + "else:\n" + " try:\n" + " from airflow.sdk.log import mask_secret\n" + " except ImportError:\n" + " mask_secret = None\n" + "if TYPE_CHECKING:\n" + " from compat.types import Starred\n" + "elif SOMETHING:\n" + " from compat import *\n"; + CBMFileResult *r = extract(src, CBM_LANG_PYTHON, "t", "hook.py"); + ASSERT_NOT_NULL(r); + const CBMImport *typed = py_find_import(r, "OnlyTyped"); + ASSERT_NOT_NULL(typed); + ASSERT_TRUE(typed->type_checking_only); /* only the type checker binds it */ + ASSERT_NULL(py_find_import(r, "mask_secret")); /* the else branch binds it at runtime */ + ASSERT_NULL(py_find_import(r, "Starred")); /* the elif star import may bind it */ + ASSERT_NULL(py_find_import(r, "compat")); /* alternatives stay unrecorded */ + ASSERT_NOT_NULL(py_find_import(r, "TYPE_CHECKING")); + cbm_free_result(r); + PASS(); +} + TEST(python_function) { CBMFileResult *r = extract( "def greet(name):\n return f\"Hello {name}\"\n\ndef main():\n greet(\"World\")\n", @@ -12670,6 +12702,7 @@ SUITE(extraction) { RUN_TEST(python_class); RUN_TEST(python_annotated_instance_fields_exported_issue1277); RUN_TEST(python_type_checking_imports_flagged_issue1277); + RUN_TEST(python_type_checking_rebound_in_else_unrecorded_issue1277); RUN_TEST(js_function); RUN_TEST(js_class); RUN_TEST(ts_function); From 2455e75578b4beb13574756a4363ff2d2fa29c26 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 00:26:43 +0200 Subject: [PATCH 14/17] fix(python): a TYPE_CHECKING import the else branch rebinds stays unrecorded (#1277) The compat shape imports a name under `if TYPE_CHECKING:` and again in the else branch, usually inside try/except ImportError. The name is bound at runtime there, so its TYPE_CHECKING import is no annotation-only import: recording it flagged made every CALLS import map skip the name and lost the calls and bases that bind through the runtime import (airflow mask_secret, OpenLineageRedactor(SecretsMasker)). The imports of the elif/else branches are collected (any depth, iteratively), every TYPE_CHECKING import whose local name they bind is dropped with them, and a star import there drops the whole block: those names keep the behaviour they had before #1277. Signed-off-by: Martin Vogel --- internal/cbm/extract_imports.c | 80 +++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index efce3361b..27a3d2b8f 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -354,12 +354,76 @@ static void process_py_import_node(CBMExtractCtx *ctx, TSNode node, bool type_on } } +static bool py_is_import_node(TSNode node) { + const char *kind = ts_node_type(node); + return strcmp(kind, "import_statement") == 0 || strcmp(kind, "import_from_statement") == 0 || + strcmp(kind, "future_import_statement") == 0; +} + +static bool py_import_is_wildcard(TSNode import_node) { + uint32_t n = ts_node_child_count(import_node); + for (uint32_t i = 0; i < n; i++) { + if (strcmp(ts_node_type(ts_node_child(import_node, i)), "wildcard_import") == 0) { + return true; + } + } + return false; +} + +/* Records, unflagged, every import statement anywhere under `root` (the + * runtime alternative of a TYPE_CHECKING block usually nests them in + * try/except ImportError). An iterative pre-order walk: adversarially deep + * nesting must not exhaust the stack. Returns true when one of them is a star + * import. */ +static bool py_record_subtree_imports(CBMExtractCtx *ctx, TSNode root) { + bool star = false; + TSTreeCursor cursor = ts_tree_cursor_new(root); + for (;;) { + TSNode node = ts_tree_cursor_current_node(&cursor); + bool is_import = py_is_import_node(node); + if (is_import) { + process_py_import_node(ctx, node, false); + star = star || py_import_is_wildcard(node); + } + if (!is_import && ts_tree_cursor_goto_first_child(&cursor)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cursor)) { + if (!ts_tree_cursor_goto_parent(&cursor)) { + ts_tree_cursor_delete(&cursor); + return star; + } + } + } +} + +/* Drops the imports [first, mark) whose local name an import at [mark, count) + * binds as well (every one when `star`), then the imports [mark, count). */ +static void py_drop_rebound_imports(CBMImportArray *imports, int first, int mark, bool star) { + int keep = first; + for (int i = first; i < mark; i++) { + const CBMImport *imp = &imports->items[i]; + bool rebound = star; + for (int j = mark; !rebound && j < imports->count; j++) { + rebound = imp->local_name && imports->items[j].local_name && + strcmp(imp->local_name, imports->items[j].local_name) == 0; + } + if (!rebound) { + imports->items[keep++] = *imp; + } + } + imports->count = keep; +} + /* A module-level `if TYPE_CHECKING:` holds imports a type checker sees and * the interpreter never runs (#1277: an annotated field's type is usually * imported exactly there, to avoid an import cycle). Record the imports of - * its consequence block, flagged type_checking_only; its elif/else branches + * its consequence block, flagged type_checking_only. Its elif/else branches * are runtime alternatives and stay unrecorded, as every other compound - * statement's imports do. */ + * statement's imports do; a name they bind again (the compat shape: the same + * name imported in `else:` under try/except ImportError) is bound at runtime, + * so its TYPE_CHECKING import is no annotation-only import and is dropped as + * well, and a star import there may bind any name. */ static void process_py_type_checking_block(CBMExtractCtx *ctx, TSNode if_node) { if (!py_is_type_checking_condition( ctx, ts_node_child_by_field_name(if_node, TS_FIELD("condition")))) { @@ -369,6 +433,7 @@ static void process_py_type_checking_block(CBMExtractCtx *ctx, TSNode if_node) { if (ts_node_is_null(body)) { return; } + int first = ctx->result->imports.count; TSTreeCursor cursor = ts_tree_cursor_new(body); if (ts_tree_cursor_goto_first_child(&cursor)) { do { @@ -376,6 +441,17 @@ static void process_py_type_checking_block(CBMExtractCtx *ctx, TSNode if_node) { } while (ts_tree_cursor_goto_next_sibling(&cursor)); } ts_tree_cursor_delete(&cursor); + int mark = ctx->result->imports.count; + bool star = false; + uint32_t n = ts_node_child_count(if_node); + for (uint32_t i = 0; i < n; i++) { + TSNode alt = ts_node_child(if_node, i); + const char *kind = ts_node_type(alt); + if (strcmp(kind, "elif_clause") == 0 || strcmp(kind, "else_clause") == 0) { + star = py_record_subtree_imports(ctx, alt) || star; + } + } + py_drop_rebound_imports(&ctx->result->imports, first, mark, star); } static void parse_python_imports(CBMExtractCtx *ctx) { From 45b7279f6a2b85d581a3266ccfc238891268c693 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 01:03:15 +0200 Subject: [PATCH 15/17] fix(php): index the single-file LSP type registry cbm_run_php_lsp never finalized its type registry, so every lookup during single-file resolution scanned the stdlib plus every definition of the file linearly: O(lookups x definitions), quadratic in the size of one file. The cross-file path already indexes its registry. The registry is now finalized with first-registered semantics (the first registration of a duplicate QN wins), which keeps exactly the answers of the linear scan, and re-indexed once after trait flattening adds methods. The index lives in a scratch arena that dies with the call. A test-only work ceiling (CBM_LSP_WORK_CEILING_HIT, seam builds only) lets the work-counter test stop a reverted, unbounded loop deterministically instead of hanging the runner; product builds have no ceiling. Signed-off-by: Martin Vogel --- internal/cbm/lsp/lsp_work.h | 8 ++++ internal/cbm/lsp/php_lsp.c | 17 ++++++++ internal/cbm/lsp/scope.c | 1 + tests/test_php_lsp.c | 77 +++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/internal/cbm/lsp/lsp_work.h b/internal/cbm/lsp/lsp_work.h index fa286293d..56cc39771 100644 --- a/internal/cbm/lsp/lsp_work.h +++ b/internal/cbm/lsp/lsp_work.h @@ -17,8 +17,16 @@ extern _Thread_local uint64_t cbm_lsp_work_steps; #define CBM_LSP_WORK(n) (cbm_lsp_work_steps += (uint64_t)(n)) /* Read and reset the calling thread's counter. */ uint64_t cbm_lsp_work_take(void); +/* Test-only work ceiling (0 = none). A regression test that reverts a bound + * sets it so the reverted, unbounded loop stops deterministically and the test + * reports RED instead of hanging the runner. Seam builds only: a product build + * has no ceiling, so it can never decide graph content. */ +extern _Thread_local uint64_t cbm_lsp_work_ceiling; +#define CBM_LSP_WORK_CEILING_HIT() \ + (cbm_lsp_work_ceiling != 0 && cbm_lsp_work_steps > cbm_lsp_work_ceiling) #else #define CBM_LSP_WORK(n) ((void)0) +#define CBM_LSP_WORK_CEILING_HIT() (0) #endif #endif /* CBM_LSP_WORK_H */ diff --git a/internal/cbm/lsp/php_lsp.c b/internal/cbm/lsp/php_lsp.c index f08aa3908..571b1493b 100644 --- a/internal/cbm/lsp/php_lsp.c +++ b/internal/cbm/lsp/php_lsp.c @@ -4372,6 +4372,17 @@ void cbm_run_php_lsp(CBMArena *arena, CBMFileResult *result, const char *source, } } + /* Index the registry (F12). Unindexed, every lookup below was a linear + * scan over the stdlib plus every definition of the file: O(lookups x + * definitions), quadratic in the size of one file. First-registered + * answers keep the result of that scan (the first registration of a + * duplicate QN wins), so the output does not change. Index memory goes to + * a scratch arena that dies with this call, as in cbm_run_php_lsp_cross. */ + reg.index_first_registered = true; + CBMArena idx_arena; + cbm_arena_init(&idx_arena); + cbm_registry_finalize_into(®, &idx_arena); + /* Phase C: run the resolver. */ PHPLSPContext ctx; php_lsp_init(&ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls); @@ -4401,6 +4412,11 @@ void cbm_run_php_lsp(CBMArena *arena, CBMFileResult *result, const char *source, ctx.use_count = 0; } + /* Trait flattening registered methods after the index was built; lookups + * would tail-scan them. Re-index once, first-registered as above, so the + * answers equal those of the indexed part followed by the tail scan. */ + cbm_registry_finalize_into(®, &idx_arena); + php_lsp_process_file(&ctx, root); if (ctx.debug) { @@ -4414,6 +4430,7 @@ void cbm_run_php_lsp(CBMArena *arena, CBMFileResult *result, const char *source, rc->confidence); } } + cbm_arena_destroy(&idx_arena); } /* ── Cross-file + batch ───────────────────────────────────────── */ diff --git a/internal/cbm/lsp/scope.c b/internal/cbm/lsp/scope.c index 0b3c6505c..2fcfb8aa9 100644 --- a/internal/cbm/lsp/scope.c +++ b/internal/cbm/lsp/scope.c @@ -5,6 +5,7 @@ #ifdef CBM_ENABLE_TEST_SEAMS _Thread_local uint64_t cbm_lsp_work_steps = 0; +_Thread_local uint64_t cbm_lsp_work_ceiling = 0; uint64_t cbm_lsp_work_take(void) { uint64_t v = cbm_lsp_work_steps; cbm_lsp_work_steps = 0; diff --git a/tests/test_php_lsp.c b/tests/test_php_lsp.c index 354d96c75..344bedf39 100644 --- a/tests/test_php_lsp.c +++ b/tests/test_php_lsp.c @@ -19,6 +19,9 @@ #include "cbm.h" #include "../src/pipeline/lsp_resolve.h" #include "lsp/php_lsp.h" +#include "lsp/lsp_work.h" +#include +#include #include /* ── Helpers ───────────────────────────────────────────────────── */ @@ -625,6 +628,79 @@ TEST(phplsp_class_aliased_trait_name_collision_terminates) { PASS(); } +/* ── 23e. #765 / #1265: trait flattening work is counted, bounded, linear ── + * + * The guards above prove only that extraction returns; a regression would hang + * the runner instead of failing it. These tests count the LSP work + * (CBM_LSP_WORK, which flatten_trait_into_class adds to once per registry + * function it visits) under a test-only ceiling (lsp_work.h): if a flatten + * bound is reverted, the runaway loop stops at the ceiling and the test is RED + * deterministically. No clock is read (O9). */ +enum { PHPLSP_FLATTEN_CEILING = 50000000 }; + +static uint64_t phplsp_counted_work(const char *src, int *ceiling_hit, CBMFileResult **out) { + (void)cbm_lsp_work_take(); + cbm_lsp_work_ceiling = PHPLSP_FLATTEN_CEILING; + CBMFileResult *r = extract_php(src); + uint64_t work = cbm_lsp_work_take(); + cbm_lsp_work_ceiling = 0; + *ceiling_hit = work > PHPLSP_FLATTEN_CEILING; + if (out) { + *out = r; + } else if (r) { + cbm_free_result(r); + } + return work; +} + +/* F12: the single-file PHP LSP (cbm_run_php_lsp) never indexed its registry, + * so every type and method lookup was a linear scan over the stdlib plus every + * definition of the file. Each class here looks up the previous class and its + * method once: twice the classes must cost about twice the counted work; the + * linear scans drift toward four. */ +static char *phplsp_chain_classes_source(int n) { + size_t sz = (size_t)n * 96 + 256; + char *src = malloc(sz); + if (!src) { + return NULL; + } + size_t pos = (size_t)snprintf(src, sz, + "m(); } }\n", + i, i - 1); + } + return src; +} + +TEST(phplsp_single_file_lookup_work_is_linear_f12) { + enum { N1 = 1000, N2 = 2000 }; + char *s1 = phplsp_chain_classes_source(N1); + char *s2 = phplsp_chain_classes_source(N2); + ASSERT_NOT_NULL(s1); + ASSERT_NOT_NULL(s2); + int hit1 = 0; + int hit2 = 0; + CBMFileResult *r2 = NULL; + uint64_t w1 = phplsp_counted_work(s1, &hit1, NULL); + uint64_t w2 = phplsp_counted_work(s2, &hit2, &r2); + free(s1); + free(s2); + printf(" chained classes %d -> %d: work %llu -> %llu (ratio %.2f)\n", N1, N2, + (unsigned long long)w1, (unsigned long long)w2, w1 ? (double)w2 / (double)w1 : 0.0); + ASSERT_NOT_NULL(r2); + ASSERT_FALSE(hit1); + ASSERT_FALSE(hit2); + ASSERT(w1 > 0); + /* The last class's call still resolves to the previous class's method. */ + ASSERT(find_resolved(r2, "C1999.m", "C1998.m") >= 0); + cbm_free_result(r2); + ASSERT(w2 * 10 <= w1 * 25); /* ratio <= 2.5 */ + PASS(); +} + /* ── 24. Match expression result type ──────────────────────────── */ TEST(phplsp_match_result_type) { @@ -5671,6 +5747,7 @@ SUITE(php_lsp) { RUN_TEST(phplsp_trait_self_use_terminates); RUN_TEST(phplsp_trait_aliased_import_name_collision_terminates); RUN_TEST(phplsp_class_aliased_trait_name_collision_terminates); + RUN_TEST(phplsp_single_file_lookup_work_is_linear_f12); RUN_TEST(phplsp_match_result_type); RUN_TEST(phplsp_ternary_result_type); RUN_TEST(phplsp_method_chain_depth_three); From 669911ce60c4b701ac49a97f7fdb6ccc00795a21 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 01:40:56 +0200 Subject: [PATCH 16/17] style(lsp): clang-format the work-ceiling macro Signed-off-by: Martin Vogel --- internal/cbm/lsp/lsp_work.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cbm/lsp/lsp_work.h b/internal/cbm/lsp/lsp_work.h index 56cc39771..4f7b2bf34 100644 --- a/internal/cbm/lsp/lsp_work.h +++ b/internal/cbm/lsp/lsp_work.h @@ -22,7 +22,7 @@ uint64_t cbm_lsp_work_take(void); * reports RED instead of hanging the runner. Seam builds only: a product build * has no ceiling, so it can never decide graph content. */ extern _Thread_local uint64_t cbm_lsp_work_ceiling; -#define CBM_LSP_WORK_CEILING_HIT() \ +#define CBM_LSP_WORK_CEILING_HIT() \ (cbm_lsp_work_ceiling != 0 && cbm_lsp_work_steps > cbm_lsp_work_ceiling) #else #define CBM_LSP_WORK(n) ((void)0) From 3ead775d0bfbdd538794e92bbeee452b50d49007 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 02:11:45 +0200 Subject: [PATCH 17/17] fix(python): skip annotation-only imports without a new raw free The memory-core linter counts raw allocator calls per file and fails when a file grows. The TYPE_CHECKING change freed the copied local name of an annotation-only import with a new raw free in two import-map builders. build_import_map now asks the predicate on the name's span in the edge properties before copying it (cbm_imports_local_span_binds_runtime_name, which cbm_imports_local_binds_runtime_name now wraps), and the cross-LSP builder routes an annotation-only name through its existing no-value path. Same behaviour, no new raw allocator site. Signed-off-by: Martin Vogel --- internal/cbm/cbm.c | 13 ++++++++++--- internal/cbm/cbm.h | 4 ++++ src/pipeline/pass_calls.c | 28 ++++++++++++++++++---------- src/pipeline/pass_lsp_cross.c | 10 ++++------ 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index a69bd1b8a..23b871dbc 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -402,14 +402,16 @@ void cbm_imports_push(CBMImportArray *arr, CBMArena *a, CBMImport imp) { arr->items[arr->count++] = imp; } -bool cbm_imports_local_binds_runtime_name(const CBMImportArray *imports, const char *local_name) { - if (!imports || !local_name) { +bool cbm_imports_local_span_binds_runtime_name(const CBMImportArray *imports, const char *name, + size_t len) { + if (!imports || !name) { return true; } bool seen = false; for (int i = 0; i < imports->count; i++) { const CBMImport *imp = &imports->items[i]; - if (!imp->local_name || strcmp(imp->local_name, local_name) != 0) { + if (!imp->local_name || strncmp(imp->local_name, name, len) != 0 || + imp->local_name[len] != '\0') { continue; } if (cbm_import_binds_runtime_name(imp)) { @@ -420,6 +422,11 @@ bool cbm_imports_local_binds_runtime_name(const CBMImportArray *imports, const c return !seen; } +bool cbm_imports_local_binds_runtime_name(const CBMImportArray *imports, const char *local_name) { + return !local_name || + cbm_imports_local_span_binds_runtime_name(imports, local_name, strlen(local_name)); +} + void cbm_usages_push(CBMUsageArray *arr, CBMArena *a, CBMUsage usage) { GROW_ARRAY(arr, a); arr->items[arr->count++] = usage; diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index bb9799a15..504bda4fc 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -1321,6 +1321,10 @@ void cbm_imports_push(CBMImportArray *arr, CBMArena *a, CBMImport imp); * annotation-only (cbm_import_binds_runtime_name), so also true when no import * names it: the rule for the builders that key IMPORTS edges by local name. */ bool cbm_imports_local_binds_runtime_name(const CBMImportArray *imports, const char *local_name); +/* The same for the `len` bytes at `name` (not NUL-terminated): lets a caller + * decide before it copies the name. */ +bool cbm_imports_local_span_binds_runtime_name(const CBMImportArray *imports, const char *name, + size_t len); void cbm_usages_push(CBMUsageArray *arr, CBMArena *a, CBMUsage usage); void cbm_throws_push(CBMThrowArray *arr, CBMArena *a, CBMThrow thr); void cbm_rw_push(CBMRWArray *arr, CBMArena *a, CBMReadWrite rw); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 9d60ee5c9..fbbdabbe7 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -95,21 +95,24 @@ static const char *itoa_log(int val) { /* Build per-file import map from cached extraction result or graph buffer edges. * Returns parallel arrays of (local_name, module_qn) pairs. Caller frees. */ -/* Parse "local_name":"value" from JSON properties string. Returns strdup'd key or NULL. */ -static char *extract_local_name_from_json(const char *props_json) { +/* The "local_name":"value" of a JSON properties string, as a span of it; + * false when absent or empty. */ +static bool local_name_span_from_json(const char *props_json, const char **out, size_t *len) { if (!props_json) { - return NULL; + return false; } const char *start = strstr(props_json, "\"local_name\":\""); if (!start) { - return NULL; + return false; } start += strlen("\"local_name\":\""); const char *end = strchr(start, '"'); if (!end || end <= start) { - return NULL; + return false; } - return cbm_strndup(start, end - start); + *out = start; + *len = (size_t)(end - start); + return true; } /* The graph node whose QN is cbm_pipeline_fqn_compute(project, rel_path, name), @@ -193,11 +196,16 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, if (!target) { continue; } - char *key = extract_local_name_from_json(e->properties_json); - if (key && !cbm_imports_local_binds_runtime_name(result ? &result->imports : NULL, key)) { - free(key); /* annotation-only import (#1277): no CALLS binding */ - key = NULL; + const char *name = NULL; + size_t name_len = 0; + /* An annotation-only import (#1277) binds no CALLS name: skipped before + * the key is copied. */ + if (!local_name_span_from_json(e->properties_json, &name, &name_len) || + !cbm_imports_local_span_binds_runtime_name(result ? &result->imports : NULL, name, + name_len)) { + continue; } + char *key = cbm_strndup(name, name_len); if (key) { keys[count] = key; vals[count] = target->qualified_name; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index b1fa5f264..4f8ab8280 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -1147,12 +1147,10 @@ int cbm_pxc_build_import_map_ex(const cbm_gbuf_t *gbuf, const char *project_name continue; memcpy(local, start, n); local[n] = '\0'; - if (!include_type_only && - !cbm_imports_local_binds_runtime_name(result ? &result->imports : NULL, local)) { - free(local); /* annotation-only import (#1277): no CALLS binding */ - continue; - } - char *value = pxc_import_value_qn(lang, result, local, target); + /* An annotation-only import (#1277) binds no CALLS name: it gets no value. */ + bool binds = include_type_only || + cbm_imports_local_binds_runtime_name(result ? &result->imports : NULL, local); + char *value = binds ? pxc_import_value_qn(lang, result, local, target) : NULL; if (!value) { free(local); continue;