Repository navigation
Description
Summary
Python cross-file receiver inference loses instance-attribute types. A method call through an imported class's typed field remains unresolved even when the cross-file LSP definition explicitly supplies that field type.
In a full index, the unresolved call can then be claimed by generic fallback resolution and become a low-confidence edge to an unrelated/arbitrary concrete implementation.
This is a general Python pattern, independent of any particular application.
Minimal source shape
contracts.py:
class Contract:
def process_batch(self) -> None:
...trainer.py:
from contracts import Contract
class Trainer:
def __init__(self, strategies: Contract) -> None:
self.strategies = strategiesloop.py:
from trainer import Trainer
def run(trainer: Trainer) -> None:
strategies = trainer.strategies
strategies.process_batch()Expected: run -> contracts.Contract.process_batch.
Observed: Python LSP does not resolve strategies.process_batch(). In a real project with several concrete process_batch implementations, the later generic resolver emitted a suffix_match edge to one arbitrary concrete class at confidence 0.17.
Direct unit-level reproducer
Confirmed on upstream HEAD c1cf8e2 by adding this case to tests/test_py_lsp.c:
TEST(pylsp_crossfile_receiver_through_typed_field) {
const char *source =
"from trainer import Trainer\n"
"def run(trainer: Trainer):\n"
" strategies = trainer.strategies\n"
" return strategies.process_batch()\n";
CBMLSPDef defs[3];
memset(defs, 0, sizeof(defs));
defs[0].qualified_name = "contracts.Contract";
defs[0].short_name = "Contract";
defs[0].label = "Class";
defs[0].def_module_qn = "contracts";
defs[1].qualified_name = "contracts.Contract.process_batch";
defs[1].short_name = "process_batch";
defs[1].label = "Method";
defs[1].receiver_type = "contracts.Contract";
defs[1].def_module_qn = "contracts";
defs[2].qualified_name = "trainer.Trainer";
defs[2].short_name = "Trainer";
defs[2].label = "Class";
defs[2].def_module_qn = "trainer";
defs[2].field_defs = "strategies:contracts.Contract";
const char *imp_names[] = {"Trainer"};
const char *imp_qns[] = {"trainer.Trainer"};
CBMArena arena;
cbm_arena_init(&arena);
CBMResolvedCallArray out = {0};
cbm_run_py_lsp_cross(&arena, source, (int)strlen(source), "test.loop",
defs, 3, imp_names, imp_qns, 1, NULL, &out);
ASSERT_GTE(find_resolved_arr(&out, "run", "process_batch"), 0);
cbm_arena_destroy(&arena);
PASS();
}Result:
pylsp_crossfile_receiver_through_typed_field
FAIL: find_resolved_arr(&out, "run", "process_batch") returned -1
The existing pylsp_crossfile_method_dispatch test still passes because its receiver is a directly annotated parameter. The failure is specifically the attribute hop:
trainer: Trainer
-> trainer.strategies: Contract
-> local alias strategies
-> strategies.process_batch()
Likely implementation gaps
Two gaps appear to explain the behavior:
src/pipeline/pass_lsp_cross.c::pxc_build_lsp_def()copies return types and embedded/base types, but does not populateCBMLSPDef.field_defsfrom extracted Python class fields.internal/cbm/lsp/py_lsp.c::py_register_lsp_defs()registers types, embedded types, and methods, but does not consumefield_defs. The direct test above therefore fails even whenfield_defsis supplied manually.
Same-file field inference works through the per-file overlay, so this is specifically a cross-file metadata/registry gap.
Compounding TYPE_CHECKING import gap
A common variant imports the annotation only for static checking:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from trainer import Trainer
def run(trainer: Trainer) -> None:
trainer.strategies.process_batch()internal/cbm/extract_imports.c::parse_python_imports() currently scans only direct root children, so it does not descend into if TYPE_CHECKING: and no IMPORTS binding for Trainer is emitted.
The existing pylsp_import_typing_only_still_binds test manually passes an import binding to the resolver; it does not test import extraction from an actual if TYPE_CHECKING: block.
This is separable from field metadata, but both occur in the same normal receiver-inference chain. I am happy to split the TYPE_CHECKING extraction gap into a follow-up if preferred.
Desired behavior
- Export Python instance-field types into the project-wide LSP definitions/registry.
- Consume those field definitions when resolving attributes on imported classes.
- Preserve the type through local aliases.
- Discover type-only imports nested under
if TYPE_CHECKING:, or otherwise bind their names for annotation resolution. - If only the contract/base type is known, resolve to that contract method or remain unresolved—never select an arbitrary concrete implementation.
Related issues
- Python polymorphic call is collapsed to one low-confidence concrete target instead of override set #1271 — downstream polymorphic fallback collapses calls to one concrete implementation.
- Python unresolved member calls produce false-positive CALLS edges via suffix_match and field_type_hint #1276 — unresolved member calls can become false-positive
CALLSedges viasuffix_match/field_type_hint.
This issue is about the earlier loss of receiver type that makes those downstream failures possible.
Activity
DeusData commentedon Jul 28, 2026
Thanks for the direct LSP-level reproducer and for separating the TYPE_CHECKING import gap.
Current main carries file-level LSP definitions across the project, but Python instance-field type information is not exported into that cross-file definition contract; the same-file overlay is therefore richer than the imported-class path. parse_python_imports also remains rooted in direct syntax children, so the nested TYPE_CHECKING case is a second, separable gap.
I have routed the core cross-file receiver-type loss to 0.9.1-rc at priority/high. Please keep the nested import extraction as a separate follow-up if you pursue it, so each regression stays binding. Thank you.
DeusData commentedon Sep 26, 2026
Thank you, @Enferlain, for the unit-level reproducer, and for #1324, which we distilled into b6a2284 (in v0.11.0)!
The second half of your report is gone on current main: an unresolved
strategies.process_batch()no longer gets a low-confidencesuffix_matchedge to an arbitrary concrete class. The core gap is still real, though. With the typed field on a class in the same file, the call resolves toContract.process_batch; withTrainerimported from another file, it doesn't. The cross-file definitions only carry field types for Go structs (pxc_fold_go_struct_fieldsinpass_lsp_cross.c), so Python class fields never reach the cross-file resolver.The fix is to export annotated instance fields and look them up while resolving the receiver. This stays on our list, and your test case will be the failing regression test.
fix(lsp): detach field arrays before overlay refinement
fix(python): retain typed fields across file boundaries
Merge pull request #2700 from DeusData/fix/py-type-checking-imports-v2
test(python): annotations resolve through TYPE_CHECKING imports (Deus…
fix(python): resolve annotations through TYPE_CHECKING imports (DeusD…
test(python): a name the else branch rebinds is no TYPE_CHECKING-only…
fix(python): a TYPE_CHECKING import the else branch rebinds stays unr…
DeusData commentedon Oct 19, 2026
Thank you again for the cross-file typed-field fixture. The last update separated the suppressed false fallback from the still-missing Python field-type propagation; I have no newer fix for that core gap to report. No additional reproduction is requested now.
DeusData commentedon Oct 25, 2026
Thank you for the cross-file typed-field fixture. Suppressing false fallback did not supply the missing field-type propagation; that core gap remains for maintainer follow-through. Sorry for the delay; this is waiting on maintainer follow-up, and I’ll follow up soon.