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 = strategies
loop.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 populate CBMLSPDef.field_defs from extracted Python class fields.
internal/cbm/lsp/py_lsp.c::py_register_lsp_defs() registers types, embedded types, and methods, but does not consume field_defs. The direct test above therefore fails even when field_defs is 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
This issue is about the earlier loss of receiver type that makes those downstream failures possible.
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:trainer.py:loop.py:Expected:
run -> contracts.Contract.process_batch.Observed: Python LSP does not resolve
strategies.process_batch(). In a real project with several concreteprocess_batchimplementations, the later generic resolver emitted asuffix_matchedge to one arbitrary concrete class at confidence 0.17.Direct unit-level reproducer
Confirmed on upstream HEAD
c1cf8e2by adding this case totests/test_py_lsp.c:Result:
The existing
pylsp_crossfile_method_dispatchtest still passes because its receiver is a directly annotated parameter. The failure is specifically the attribute hop: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_CHECKINGimport gapA common variant imports the annotation only for static checking:
internal/cbm/extract_imports.c::parse_python_imports()currently scans only direct root children, so it does not descend intoif TYPE_CHECKING:and noIMPORTSbinding forTraineris emitted.The existing
pylsp_import_typing_only_still_bindstest manually passes an import binding to the resolver; it does not test import extraction from an actualif 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_CHECKINGextraction gap into a follow-up if preferred.Desired behavior
if TYPE_CHECKING:, or otherwise bind their names for annotation resolution.Related issues
CALLSedges viasuffix_match/field_type_hint.This issue is about the earlier loss of receiver type that makes those downstream failures possible.