← board

The field pre-pass asks one question in six places

What was counted

PyInferFieldDecl, 269 lines, 13 arms. They are three questions, not thirteen:

question arms notes
what does this EXPRESSION evaluate to PyInferExprType, PyTypeFromTokenIndex (x2), the qualified-construction reader already carries class IDENTITY via PyInferLastCi
where is this NAME bound, and to what six readers, three scopes the duplication
what to do when nothing types it None / not-in-ctor / int-accumulator / ErrorAt policy, correctly separate

The identity point is measured, not assumed: self.k = K() has no dot, so the qualified-construction arm cannot fire, and PyBlkRhsEndsAt is false, so the single-token readers cannot either. It prints 9. PyInferExprType answered it with the class identity.

What collapsed, and what it cost

The three module-global readers were one loop written three times — same depth counter, same atStart predicate, same PyScanLo..MainProgramTokCount bounds — differing only in the statement-head pattern and the return type. PyModuleBindingsOf is now that scan once; the three are thin callers. No answer changed, asserted by every rung's probe before and after.

The hazard found while collapsing is the part to keep. The three readers DISAGREE about a global rebound at module level: the literal reader lets the LAST binding win, the constructor reader scans past bindings that do not match its shape, and today's answer is an accident of the order they are consulted in. A resolver that returned "the first binding" — the obvious collapse — would type RB2 = J(); RB2 = 7 as a J holding a 7. A wrong LAYOUT, which compiles and prints a plausible number. Guarded now by the last two rows of test/test_nilpy_field_from_a_module_global_expression.npy.

What did NOT collapse, and why that is a real answer

PyHeaderParamType reads an ANNOTATION, not an initialiser — a parameter has no initialiser to infer from, so it is genuinely a different read. The annotation arm is a DECLARATION. The policy arms are policy. Those four stay.

The root cause, and the shape of the rest

The pre-pass runs before anything knows a name's type, so each scope grew its own token-scanner. PyLocals is a flat array with a linear name scan and no scope field (pyparser.inc:76), which is why seeding module globals into it before this pre-pass — the obvious fix, and the first one tried — made a def-local loop target inherit a same-named module global's type. test_nilpy_loop_target_in_a_def_is_local.npy exists because it caught that.

So the remaining work is a name -> binding-site resolver per scope feeding the one expression scanner, which would delete the two method-local readers and fix a shape that is refused today for free: FN = named then self.fn = FN (the direct self.fn = named works). That is the tell this rule predicts — of several paths for one concept, the one nobody extended is the one that stays broken.

Same rule, same evening, different subsystem: bug-n-a-chained-assignment-to-two-attributes-does-not-parse.