Skip to content

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

Open

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.

Activity

  1. added
    bugSomething isn't working
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    on Jul 28, 2026
  2. added this to the 0.9.1-rc milestone on Jul 28, 2026
  3. DeusData commented on Jul 28, 2026

    @DeusData
    Owner

    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.

  4. DeusData commented on Sep 26, 2026

    @DeusData
    Owner

    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-confidence suffix_match edge 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 to Contract.process_batch; with Trainer imported from another file, it doesn't. The cross-file definitions only carry field types for Go structs (pxc_fold_go_struct_fields in pass_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.

  5. added a commit that references this issue on Oct 19, 2026
  6. DeusData commented on Oct 19, 2026

    @DeusData
    Owner

    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.

  7. DeusData commented on Oct 25, 2026

    @DeusData
    Owner

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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