From 054719044a6e3f349f1c17f3d06d242b6b35bd14 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:27:32 +0100 Subject: [PATCH] fix(python): bind ClassName() only to the class own __init__ A construction call now resolves to __init__ only when the called class declares that __init__ itself. An inherited __init__ is no longer followed: class B(A) without its own __init__ keeps the B() call on the Class B, as it was before this branch. Following the bases bound every subclass construction to the base constructor. On a django index (fb113765) that made Model.__init__ a 583-caller hub (every model instantiation), bound IntegerField() to Field.__init__ and HttpResponseForbidden() to HttpResponse.__init__, and degraded 625 constructor call sites versus main (457 fell back to textual resolution, 168 lost their row; CALLS -161). With own __init__ only, 6016 sites move to the class own __init__, 5468 stay on the Class, none fall back to a textual strategy, and total CALLS is +4 versus main. Tests: the same-file and cross-file inherited cases now pin the Class target, and a new same-file subclass test at the LSP and the graph level covers B(A) without __init__ (stays on B, never A.__init__) and C(A) with its own __init__ (binds to C.__init__, never the class C or A.__init__). The strategy inventory comment in the repro suite is updated to match. Refs #1642 Signed-off-by: Martin Vogel --- internal/cbm/lsp/py_lsp.c | 12 +++--- tests/repro/repro_lsp_go_py.c | 10 +++-- tests/test_lsp_resolution_probe.c | 44 +++++++++++++++++++++ tests/test_py_lsp.c | 66 ++++++++++++++++++++++--------- 4 files changed, 105 insertions(+), 27 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index be7752a39..b7309bc33 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -3249,17 +3249,19 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { py_emit_resolved_call_reason(ctx, qn, "lsp_import_alias", 0.85f, fname, call_node); } else if (qn && cbm_registry_lookup_type(ctx->registry, qn)) { - /* ClassName() runs a user __init__ found on the class or, - * depth-first, a declared base (the same walk - * super().__init__ uses, not C3 MRO). Retarget only when - * this binding IS the class being called. An instance + /* ClassName() binds to the __init__ the class declares + * itself. An inherited __init__ is not followed: B(A) + * without its own __init__ keeps the call on B, so a base + * constructor does not collect every subclass + * construction as its caller. Retarget only when this + * binding IS the class being called. An instance * (`f = Foo(); f()`) keeps the class row: that call * dispatches to __call__, and a reason-join would invent * a Foo.__init__ edge. A builtins.* __init__ is not a * project constructor, so the class target stays. */ const CBMRegisteredFunc *fi = NULL; if (qn_short && strcmp(qn_short, fname) == 0) { - fi = py_lookup_attribute(ctx, qn, "__init__"); + fi = cbm_registry_lookup_method(ctx->registry, qn, "__init__"); } if (fi && fi->qualified_name && strncmp(fi->qualified_name, "builtins.", sizeof("builtins.") - 1) != 0) { diff --git a/tests/repro/repro_lsp_go_py.c b/tests/repro/repro_lsp_go_py.c index c0b219172..2b019dce9 100644 --- a/tests/repro/repro_lsp_go_py.c +++ b/tests/repro/repro_lsp_go_py.c @@ -64,10 +64,12 @@ * PYTHON STRATEGY INVENTORY — every literal "lsp_..." emitted by py_lsp.c * (grep '"lsp_' internal/cbm/lsp/py_lsp.c), with its keying site: * lsp_direct (py_lsp.c:1631) module-local f() - * lsp_constructor ClassName() with no user __init__ → the Class - * lsp_constructor_init ClassName() with a user __init__ (on the class - * or a declared base) → that __init__. The textual - * callee leaf stays the class name; reason joins it. + * lsp_constructor ClassName() where the class declares no __init__ + * of its own (an inherited one is not followed) + * → the Class + * lsp_constructor_init ClassName() where the class declares its own + * __init__ → that __init__. The textual callee + * leaf stays the class name; reason joins it. * lsp_method (py_lsp.c:1731) obj.method() on a NAMED-typed * receiver (covers self.other()) * lsp_super (py_lsp.c:1693) super().method() resolved on a diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index fd5d7c3d3..6f18c215e 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1853,6 +1853,49 @@ TEST(lrp_python_usage_instantiation) { PASS(); } +/* Python same-file subclasses in the graph: ClassName() binds only to an + * __init__ the class declares itself. B(A) declares none, so make_b keeps + * its CALLS edge on the class B and the inherited A.__init__ gets none. + * C(A) declares its own __init__, so make_c calls C.__init__, not C and not + * the inherited A.__init__. */ +TEST(lrp_python_subclass_constructor_own_init_only) { + static const LRP_File f[] = {{"main.py", "class A:\n" + " def __init__(self):\n" + " self.ready = True\n" + "\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "\n" + "class C(A):\n" + " def __init__(self):\n" + " self.ready = False\n" + "\n" + "def make_b():\n" + " return B()\n" + "\n" + "def make_c():\n" + " return C()\n"}}; + LRP_Proj lp; + cbm_store_t *store = lrp_index(&lp, f, 1); + const char *p = store ? lp.project : NULL; + int b_class = lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_b", "main.B"); + int b_base_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_b", "main.A.__init__"); + int c_own_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.C.__init__"); + int c_class = lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.C"); + int c_base_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.A.__init__"); + lrp_cleanup(&lp, store); + ASSERT_EQ(b_class, 1); + ASSERT_EQ(b_base_init, 0); + ASSERT_EQ(c_own_init, 1); + ASSERT_EQ(c_class, 0); + ASSERT_EQ(c_base_init, 0); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════════ * SUITE registration * ══════════════════════════════════════════════════════════════════════ */ @@ -1970,4 +2013,5 @@ SUITE(lsp_resolution_probe) { RUN_TEST(lrp_ts_usage_import_type_with_decoy); RUN_TEST(lrp_tsx_usage_import_type_with_decoy); RUN_TEST(lrp_python_usage_instantiation); + RUN_TEST(lrp_python_subclass_constructor_own_init_only); } diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 4f92318c0..1aa5c806d 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -403,26 +403,55 @@ TEST(pylsp_constructor_call_targets_init) { PASS(); } -/* class B(A) with only A.__init__ resolves construction of B to A.__init__. */ -TEST(pylsp_constructor_inherited_init) { +/* Same-file subclasses: only an __init__ the class declares itself is a + * construction target. B(A) inherits A.__init__ but declares none, so B() + * stays on the class B (never A.__init__). C(A) declares its own __init__, + * so C() resolves to C.__init__ (never the inherited A.__init__). */ +static int count_build_rows(const CBMFileResult *r, const char *caller_leaf, + const char *callee_qn, const char *strategy) { + int n = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !rc->strategy) + continue; + const char *dot = strrchr(rc->caller_qn, '.'); + const char *leaf = dot ? dot + 1 : rc->caller_qn; + if (strcmp(leaf, caller_leaf) == 0 && strcmp(rc->callee_qn, callee_qn) == 0 && + strcmp(rc->strategy, strategy) == 0) + n++; + } + return n; +} + +TEST(pylsp_constructor_same_file_subclass_own_init_only) { CBMFileResult *r = extract_py("class A:\n" " def __init__(self):\n" " self.ready = True\n" "class B(A):\n" " def go(self):\n" " return 1\n" - "def build():\n" - " return B()\n"); + "class C(A):\n" + " def __init__(self):\n" + " self.ready = False\n" + "def build_b():\n" + " return B()\n" + "def build_c():\n" + " return C()\n"); ASSERT_NOT_NULL(r); - int idx = require_resolved(r, "build", "A.__init__"); + /* B() keeps the class target; the inherited A.__init__ gets no row. */ + ASSERT_EQ(count_build_rows(r, "build_b", "test.main.B", "lsp_constructor"), 1); + ASSERT_EQ(find_resolved(r, "build_b", "__init__"), -1); + /* C() binds to the __init__ C declares itself. */ + int idx = require_resolved(r, "build_c", "test.main.C.__init__"); ASSERT_GTE(idx, 0); if (idx >= 0) { const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); - ASSERT(strstr(rc->callee_qn, "B.__init__") == NULL); - ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + ASSERT_STR_EQ(rc->reason, "C"); } - ASSERT_EQ(find_resolved(r, "build", "B.__init__"), -1); + ASSERT_EQ(find_resolved(r, "build_c", "test.main.A.__init__"), -1); + ASSERT_EQ(count_build_rows(r, "build_c", "test.main.C", "lsp_constructor"), 0); cbm_free_result(r); PASS(); } @@ -1126,10 +1155,11 @@ TEST(pylsp_crossfile_constructor_targets_init) { PASS(); } -/* class B(A) where A's __init__ lives in another module. The cross registry - * records the real base QN; per-file registration would have stored - * test.main.A and missed the base. */ -TEST(pylsp_crossfile_inherited_init) { +/* class B(A) where A's __init__ lives in another module and B declares no + * __init__ of its own. Construction binds only to an __init__ the class + * declares itself, so B() stays on the class B and the inherited + * base_mod.A.__init__ gets no row. */ +TEST(pylsp_crossfile_inherited_init_stays_on_class) { const char *source = "from base_mod import A\n" "class B(A):\n" " def go(self):\n" @@ -1168,12 +1198,12 @@ TEST(pylsp_crossfile_inherited_init) { cbm_run_py_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, imp_names, imp_qns, 1, NULL, &out, NULL); - int idx = find_resolved_arr(&out, "build", "base_mod.A.__init__"); + ASSERT_EQ(find_resolved_arr(&out, "build", "__init__"), -1); + int idx = find_resolved_arr(&out, "build", "test.main.B"); ASSERT_GTE(idx, 0); if (idx >= 0) { - ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor_init"); - ASSERT_STR_EQ(out.items[idx].reason, "B"); - ASSERT(strstr(out.items[idx].callee_qn, "B.__init__") == NULL); + ASSERT_STR_EQ(out.items[idx].callee_qn, "test.main.B"); + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor"); } cbm_arena_destroy(&arena); PASS(); @@ -5266,7 +5296,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_method_via_self); RUN_TEST(pylsp_constructor_call_returns_instance); RUN_TEST(pylsp_constructor_call_targets_init); - RUN_TEST(pylsp_constructor_inherited_init); + RUN_TEST(pylsp_constructor_same_file_subclass_own_init_only); RUN_TEST(pylsp_constructor_without_init_stays_on_class); RUN_TEST(pylsp_callable_instance_call_skips_init); RUN_TEST(pylsp_constructor_skips_builtin_init); @@ -5289,7 +5319,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); RUN_TEST(pylsp_crossfile_constructor_targets_init); - RUN_TEST(pylsp_crossfile_inherited_init); + RUN_TEST(pylsp_crossfile_inherited_init_stays_on_class); RUN_TEST(pylsp_batch_two_files); RUN_TEST(pylsp_from_import_alias_equal_module_leaf_targets_imported_member); RUN_TEST(pylsp_project_prefixed_direct_alias_same_tail_is_not_from_import_reference);