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:
- An annotated
name: T = exprreads the annotation and skips the RHS — intended, and the escape hatch for everything else. - A bare assignment whose RHS calls a method on a NAME (
x = c.two(1)) is skipped. Class MEMBERS are not registered untilPyParseClassreaches 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). - 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.