← board

NilPy: type MODULE locals from the AST too

Remainder of feature-n-nilpy-ast-based-typing (resolved 1a4089b4). Def and method bodies now type their locals by parsing (PyCollectLocalsAST); module scope still uses the token scanner PyCollectModuleLocals -> PyInferExprType, so the drift this ticket set out to kill still exists at module level.

Why it was left

A def body is one parseable block with a known start token, so the trial parse is a straight TokPos := bodyStart; PyParseBlock. Module scope is not: it interleaves def, class and statements, and PyCollectModuleLocals walks it with an indent-depth filter to skip anything nested. A trial parse would have to run the whole program's top level — including registering procs and classes — and roll all of that back, which is a much bigger blast radius than a body.

Status: mostly LANDED (226f2507)

PyCollectModuleLocalsAST trial-parses the module body — enabled by PyRegisterDefShells (ba546669), which registers top-level def signatures up front so a module statement may call a def declared further down. PyCollectModuleLocals is gone.

What remains, and why. Three narrowings keep the pre-pass off ground it cannot stand on. Only the second is a real gap:

  1. An annotated name: T = expr reads the annotation and skips the RHS — intended, and the escape hatch for everything else.
  2. A bare assignment whose RHS calls a method on a NAME (x = c.two(1)) is skipped. Class MEMBERS are not registered until PyParseClass reaches the class, so trial-parsing it would fail on a method that is valid a moment later. Cost: no WIDENING for that name (the real parse still declares it).
  3. Only assignments are parsed; nothing else declares a module local.

To close (2): hoist class member registration the way def signatures now are. PyRegisterClassMembers already has a fieldsOnly flag and is already run twice (fields pre-pass, then PyParseClass). The obstacle is that the non-fieldsOnly path also appends to the PyDc* dataclass-default tables, so a third run would duplicate them — untangle that first, then give PyParseClass a "members already registered" guard.

PyInferExprType survives for ONE caller: the ctor field scan, which has no parseable block of its own — fields must exist before any body is parsed. See [[project_nilpy_class_pipeline_ordering]]. Closing that is the same declaration-phase work as (2).

Recon 2026-07-31 — confirmed still genuinely open, not stale

Given how many other tickets this session turned out to already be fixed, re-checked this one for real rather than assuming. It is NOT stale — the exact "x = c.two(1)" repro from item (2) above still reproduces:

class C:
    def two(self, v): return v * 2
c = C()
x = c.two(1)
print(x)      # CPython: 2      pxx: 2.0
x = 3.5
print(x)      # both: 3.5

x's first binding (c.two(1), an int) gets silently widened to float because the module pre-pass never saw it as a note-worthy assignment (the method-call RHS is skipped, per item 2), so the LATER x = 3.5 binding is the only one the widening table learns about, and the whole slot renders as float from the start — the exact "no WIDENING for that name" defect this ticket already predicted, still present. Not attempted this pass: the fix needs untangling PyRegisterClassMembers's dual dataclass-table role first (a real, if bounded, refactor of the class-member registration pipeline), which is more than a quick patch and carries real risk of subtly breaking dataclass defaults if rushed.