Skip to content

Python cross-file receiver inference loses typed instance fields #1277

Description

@Enferlain

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingparsing/qualityGraph extraction bugs, false positives, missing edgespriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions