From 1b64c50c54a804fa688c311de625049d704eed70 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:17:03 -0700 Subject: [PATCH 1/3] fix(python): resolve ClassName() calls to __init__ A Python construction call resolved to the Class node, so a user __init__ never received a caller from ClassName(...) and the dead-code view reported live constructors as unreferenced. When the called name is the class itself, resolve it to the __init__ found on the class or, depth-first, its declared bases (the lookup super().__init__() already uses), with a new lsp_constructor_init strategy that joins the textual call site through its reason. Classes with no user __init__ keep the Class target (lsp_constructor), as does a builtins.* __init__. Calls through an instance (f = Foo(); f()) are unchanged, since they dispatch to __call__. This changes the documented Class-target contract for classes that define __init__; the three tests that pinned it are updated. Refs #1642 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- internal/cbm/lsp/py_lsp.c | 21 ++- src/pipeline/lsp_resolve.h | 1 + tests/repro/repro_lsp_go_py.c | 30 +++- tests/test_edge_structural.c | 22 ++- tests/test_lsp_resolution_probe.c | 7 +- tests/test_py_lsp.c | 262 ++++++++++++++++++++++++++++++ 6 files changed, 332 insertions(+), 11 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 74c1980e2..be7752a39 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -3249,7 +3249,26 @@ 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)) { - py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f, call_node); + /* 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 + * (`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__"); + } + if (fi && fi->qualified_name && + strncmp(fi->qualified_name, "builtins.", sizeof("builtins.") - 1) != 0) { + py_emit_resolved_call_reason(ctx, fi->qualified_name, + "lsp_constructor_init", 0.85f, fname, + call_node); + } else { + py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f, call_node); + } } } return; diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 371cb5536..7fd3905d3 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -388,6 +388,7 @@ static inline bool cbm_pipeline_invocation_reason_join_strategy(const char *stra strcmp(strategy, "lsp_method_ref_ctor_synth") == 0 || strcmp(strategy, "lsp_dict_dispatch") == 0 || strcmp(strategy, "lsp_import_alias") == 0 || + strcmp(strategy, "lsp_constructor_init") == 0 || strcmp(strategy, "php_method_dynamic") == 0); } diff --git a/tests/repro/repro_lsp_go_py.c b/tests/repro/repro_lsp_go_py.c index fcb06903d..c0b219172 100644 --- a/tests/repro/repro_lsp_go_py.c +++ b/tests/repro/repro_lsp_go_py.c @@ -64,8 +64,10 @@ * 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 (py_lsp.c:1624) ClassName() where the name is a - * NAMED type in scope + * 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_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 @@ -340,13 +342,24 @@ static const char kPyDirect[] = "def caller(v):\n" " return helper(v)\n"; -/* lsp_constructor — ClassName() where the name is a NAMED type in scope - * (py_lsp.c:1620-1624: cbm_scope_lookup yields a NAMED type → emit constructor - * edge to the class QN). */ +/* lsp_constructor — ClassName() where the class has no user __init__. + * The edge stays on the Class node. A class that defines __init__ is + * lsp_constructor_init below; strstr would otherwise treat that strategy + * as a hit for "lsp_constructor". */ static const char kPyConstructor[] = + "class Widget:\n" + " def label(self):\n" + " return 1\n" + "def caller():\n" + " return Widget()\n"; + +/* lsp_constructor_init — ClassName() resolves to the user __init__ + * (issue #1642). reason carries the class-name leaf so the pipeline join + * still matches the textual call. */ +static const char kPyConstructorInit[] = "class Widget:\n" " def __init__(self):\n" - " pass\n" + " self.ready = True\n" "def caller():\n" " return Widget()\n"; @@ -538,6 +551,10 @@ TEST(repro_lsp_py_constructor) { return assert_lsp_strategy("main.py", kPyConstructor, "lsp_constructor"); } +TEST(repro_lsp_py_constructor_init) { + return assert_lsp_strategy("main.py", kPyConstructorInit, "lsp_constructor_init"); +} + TEST(repro_lsp_py_method) { return assert_lsp_strategy("main.py", kPyMethod, "lsp_method"); } @@ -623,6 +640,7 @@ SUITE(repro_lsp_go_py) { RUN_TEST(repro_lsp_py_direct); RUN_TEST(repro_lsp_py_constructor); + RUN_TEST(repro_lsp_py_constructor_init); RUN_TEST(repro_lsp_py_method); RUN_TEST(repro_lsp_py_super); RUN_TEST(repro_lsp_py_super_init); diff --git a/tests/test_edge_structural.c b/tests/test_edge_structural.c index 4b91ae05f..67326dcd5 100644 --- a/tests/test_edge_structural.c +++ b/tests/test_edge_structural.c @@ -826,14 +826,31 @@ TEST(es_decorates_attribute_csharp) { * resolves in the registry. pass_usages.c handles this. * ══════════════════════════════════════════════════════════════════ */ -/* Python constructor syntax is a CALLS edge to the materialized Class node. */ +/* Python ClassName() with a user __init__ is a CALLS edge to that method. + * The Class node no longer receives the instantiation edge. A class with no + * user __init__ still targets the Class (lsp_constructor). */ TEST(es_usage_crossfile_python) { static const ES_LangFile f[] = { {"models.py", "class User:\n def __init__(self, name):\n self.name = name\n"}, {"main.py", "from .models import User\n\n\ndef create_user(name):\n return User(name)\n"}}; - ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "User"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "__init__"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "User"), 0); + PASS(); +} + +/* Issue #1642 repro: build() -> Embedder.__init__, not the Embedder class. */ +TEST(es_calls_python_constructor_init) { + static const ES_LangFile f[] = { + {"main.py", "class Embedder:\n" + " def __init__(self, model=\"x\"):\n" + " self.model = model\n" + "\n" + "def build():\n" + " return Embedder(model=\"y\")\n"}}; + ASSERT_EQ(es_exact_edge_by_name(f, 1, "CALLS", "build", "__init__"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 1, "CALLS", "build", "Embedder"), 0); PASS(); } @@ -982,6 +999,7 @@ SUITE(edge_structural) { /* ── FAMILY 7: USAGE cross-file ─────────────────────────── */ /* Uncertain: depends on USAGE extraction for each language. */ RUN_TEST(es_usage_crossfile_python); + RUN_TEST(es_calls_python_constructor_init); RUN_TEST(es_usage_crossfile_typescript); RUN_TEST(es_usage_crossfile_go); diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 91844f67e..fd5d7c3d3 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1837,16 +1837,19 @@ TEST(lrp_tsx_usage_import_type_with_decoy) { PASS(); } -/* Python: constructor syntax is an exact call to the materialized Class node. */ +/* Python: ClassName() with a user __init__ is an exact CALLS edge to that + * method. The Class node no longer receives the instantiation edge. */ TEST(lrp_python_usage_instantiation) { static const LRP_File f[] = { {"model.py", "class User:\n def __init__(self, name):\n self.name = name\n"}, {"main.py", "from .model import User\n\n\ndef create(name):\n return User(name)\n"}}; LRP_Proj lp; cbm_store_t *store = lrp_index(&lp, f, 2); - int exact = store ? lrp_exact_calls_by_name(store, lp.project, "create", "User") : -1; + int exact = store ? lrp_exact_calls_by_name(store, lp.project, "create", "__init__") : -1; + int class_edge = store ? lrp_exact_calls_by_name(store, lp.project, "create", "User") : -1; lrp_cleanup(&lp, store); ASSERT_EQ(exact, 1); + ASSERT_EQ(class_edge, 0); PASS(); } diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 429fb6628..4f92318c0 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -356,6 +356,164 @@ TEST(pylsp_constructor_call_returns_instance) { PASS(); } +/* Issue #1642: Embedder(...) must resolve to Embedder.__init__, and the + * textual callee leaf "Embedder" must still join that row. */ +TEST(pylsp_constructor_call_targets_init) { + const char *source = "class Embedder:\n" + " def __init__(self, model=\"x\"):\n" + " self.model = model\n" + "def build():\n" + " return Embedder(model=\"y\")\n"; + CBMFileResult *r = extract_py(source); + ASSERT_NOT_NULL(r); + int idx = require_resolved(r, "build", "Embedder.__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_STR_EQ(rc->reason, "Embedder"); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 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 || + !strstr(rc->caller_qn, "build")) + continue; + /* One site, one target: the class node no longer receives the call. */ + ASSERT(strstr(rc->callee_qn, ".__init__") != NULL); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } + + const CBMCall *carrier = NULL; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *call = &r->calls.items[i]; + if (call->callee_name && strcmp(call->callee_name, "Embedder") == 0 && + call->enclosing_func_qn && strstr(call->enclosing_func_qn, "build")) { + carrier = call; + break; + } + } + ASSERT_NOT_NULL(carrier); + const CBMResolvedCall *joined = + cbm_pipeline_find_lsp_resolution(&r->resolved_calls, carrier, false); + ASSERT_NOT_NULL(joined); + ASSERT(strstr(joined->callee_qn, "Embedder.__init__") != NULL); + ASSERT_STR_EQ(joined->strategy, "lsp_constructor_init"); + cbm_free_result(r); + PASS(); +} + +/* class B(A) with only A.__init__ resolves construction of B to A.__init__. */ +TEST(pylsp_constructor_inherited_init) { + 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"); + ASSERT_NOT_NULL(r); + int idx = require_resolved(r, "build", "A.__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_EQ(find_resolved(r, "build", "B.__init__"), -1); + cbm_free_result(r); + PASS(); +} + +/* No user __init__ (and no inherited one): the call stays on the class. */ +TEST(pylsp_constructor_without_init_stays_on_class) { + CBMFileResult *r = extract_py("class Bare:\n" + " def method(self):\n" + " return 1\n" + "def build():\n" + " return Bare()\n"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(find_resolved(r, "build", "__init__"), -1); + int idx = -1; + 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 && + strstr(rc->caller_qn, "build") && rc->callee_qn && strstr(rc->callee_qn, "Bare") && + !strstr(rc->callee_qn, "__init__")) { + idx = i; + break; + } + } + ASSERT_GTE(idx, 0); + if (idx >= 0) + ASSERT_STR_EQ(r->resolved_calls.items[idx].strategy, "lsp_constructor"); + cbm_free_result(r); + PASS(); +} + +/* f = Foo(); f() is a callable instance, not a constructor. It must not + * gain an edge to Foo.__init__. Foo() itself still does. */ +TEST(pylsp_callable_instance_call_skips_init) { + CBMFileResult *r = extract_py("class Foo:\n" + " def __init__(self):\n" + " pass\n" + " def __call__(self):\n" + " return 1\n" + "def use():\n" + " f = Foo()\n" + " return f()\n"); + ASSERT_NOT_NULL(r); + int init_rows = 0; + int class_rows = 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 || + !strstr(rc->caller_qn, "use")) + continue; + if (strstr(rc->callee_qn, "__init__")) { + init_rows++; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(rc->reason, "Foo"); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } else if (strstr(rc->callee_qn, "Foo")) { + class_rows++; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor"); + ASSERT_NULL(rc->reason); + } + } + ASSERT_EQ(init_rows, 1); + ASSERT_EQ(class_rows, 1); + cbm_free_result(r); + PASS(); +} + +/* builtins.object.__init__ is in the stdlib registry. Construction must not + * record a resolution to it. */ +TEST(pylsp_constructor_skips_builtin_init) { + CBMFileResult *r = extract_py("from builtins import object\n" + "def build():\n" + " return object()\n"); + ASSERT_NOT_NULL(r); + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (!rc->callee_qn) + continue; + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0 || + strstr(rc->callee_qn, ".__init__") == NULL); + } + int idx = find_resolved(r, "build", "builtins.object"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor"); + ASSERT(strstr(rc->callee_qn, "__init__") == NULL); + } + cbm_free_result(r); + PASS(); +} + TEST(pylsp_method_via_inheritance) { CBMFileResult *r = extract_py( "class Base:\n" @@ -924,6 +1082,103 @@ TEST(pylsp_crossfile_inheritance) { PASS(); } +/* Imported ClassName() resolves to that class's __init__. Bare names are + * module-qualified during per-file registration, so the imported class has + * to come from the cross-file registry. */ +TEST(pylsp_crossfile_constructor_targets_init) { + const char *source = "from models import Embedder\n" + "def build():\n" + " return Embedder(model=\"y\")\n"; + + CBMLSPDef defs[2]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "models.Embedder"; + defs[0].short_name = "Embedder"; + defs[0].label = "Class"; + defs[0].def_module_qn = "models"; + defs[0].lang = CBM_LANG_PYTHON; + defs[1].qualified_name = "models.Embedder.__init__"; + defs[1].short_name = "__init__"; + defs[1].label = "Method"; + defs[1].receiver_type = "models.Embedder"; + defs[1].def_module_qn = "models"; + defs[1].lang = CBM_LANG_PYTHON; + + const char *imp_names[] = {"Embedder"}; + const char *imp_qns[] = {"models.Embedder"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 2); + ASSERT_NOT_NULL(reg); + CBMResolvedCallArray out = {0}; + 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", "models.Embedder.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(out.items[idx].reason, "Embedder"); + ASSERT(strncmp(out.items[idx].callee_qn, "builtins.", 9) != 0); + } + cbm_arena_destroy(&arena); + 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) { + const char *source = "from base_mod import A\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "def build():\n" + " return B()\n"; + + CBMLSPDef defs[3]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "base_mod.A"; + defs[0].short_name = "A"; + defs[0].label = "Class"; + defs[0].def_module_qn = "base_mod"; + defs[0].lang = CBM_LANG_PYTHON; + defs[1].qualified_name = "base_mod.A.__init__"; + defs[1].short_name = "__init__"; + defs[1].label = "Method"; + defs[1].receiver_type = "base_mod.A"; + defs[1].def_module_qn = "base_mod"; + defs[1].lang = CBM_LANG_PYTHON; + defs[2].qualified_name = "test.main.B"; + defs[2].short_name = "B"; + defs[2].label = "Class"; + defs[2].def_module_qn = "test.main"; + defs[2].embedded_types = "base_mod.A"; + defs[2].lang = CBM_LANG_PYTHON; + + const char *imp_names[] = {"A"}; + const char *imp_qns[] = {"base_mod.A"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 3); + ASSERT_NOT_NULL(reg); + CBMResolvedCallArray out = {0}; + 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_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); + } + cbm_arena_destroy(&arena); + PASS(); +} + TEST(pylsp_batch_two_files) { const char *src_a = "def helper():\n" @@ -5010,6 +5265,11 @@ SUITE(py_lsp) { RUN_TEST(pylsp_method_call_simple); 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_without_init_stays_on_class); + RUN_TEST(pylsp_callable_instance_call_skips_init); + RUN_TEST(pylsp_constructor_skips_builtin_init); RUN_TEST(pylsp_method_via_inheritance); RUN_TEST(pylsp_no_false_positive_on_unknown_method); /* Phases 7-8 — decorators, super(), multi-inheritance */ @@ -5028,6 +5288,8 @@ SUITE(py_lsp) { RUN_TEST(pylsp_cross_registry_interleaved_order); 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_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); From aa02511cc7bdd00e17b4ebd9d38add3846765485 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:25:07 -0800 Subject: [PATCH 2/3] test(pipeline): expect Layout.__init__ for the #1548 constructor call pipeline_python_init_function_shares_submodule_qn pinned the old Class target for Layout("demo"). Layout defines __init__, so the constructor call now lands on that method. The other tests that pin the contract were moved in the previous commit; this one reached main afterwards. Refs #1642 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- tests/test_pipeline.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9157c4bcd..a4d5612e8 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -21140,8 +21140,9 @@ static int pyn_run_init_collision(int fillers) { const char *project = cbm_pipeline_project_name(p); imports[run] = pyn_edge(s, project, "tests.test_app.py.__file__", "src.rowpkg.layout.Layout", "IMPORTS"); + /* Layout defines __init__, so Layout("demo") targets it (#1642). */ calls[run] = pyn_edge(s, project, "tests.test_app.test_build", - "src.rowpkg.layout.Layout", "CALLS"); + "src.rowpkg.layout.Layout.__init__", "CALLS"); method[run] = pyn_edge(s, project, "tests.test_app.test_build", "src.rowpkg.layout.Layout.create", "CALLS"); cbm_store_close(s); @@ -21160,7 +21161,7 @@ static int pyn_run_init_collision(int fillers) { for (int run = 0; run < 2; run++) { ASSERT_EQ(rc[run], 0); ASSERT_TRUE(imports[run]); /* the class stays importable */ - ASSERT_TRUE(calls[run]); /* Layout("demo") */ + ASSERT_TRUE(calls[run]); /* Layout("demo") -> Layout.__init__ */ ASSERT_TRUE(method[run]); /* .create() through the constructed type */ } PASS(); From 054719044a6e3f349f1c17f3d06d242b6b35bd14 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:27:32 +0100 Subject: [PATCH 3/3] 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);