← board

A field assigned a module-level def has no inferable type

def named(a, b=10):
    return a + b

class H:
    def __init__(self):
        self.fn = named        # pascal26: cannot infer the type of field self.fn

h = H()
print(h.fn(1, 2))              # ...and then: H has no method .fn()

CPython prints 3. A dispatch table held in a field is ordinary Python, and both refusals are COMPILE errors, so neither could be worked around.

Found 2026-08-27 in the sibling sweep of [[bug-nilpy-a-keyword-call-through-a-statically-unknown-callee-does-not-compile]]. Pre-existing: identical on pinned v386.

Two defects, and both answers already existed elsewhere

1. The field could not be typed. The class-field pre-pass has two arms for exactly this blind spot already — a module-level LITERAL global (PyModuleGlobalLiteralType) and a global holding an INSTANCE (PyModuleGlobalCtorClass) — because "the name is bound outside this class body" and none of the body scanners can see it. A module-level def is the third member of that family and had no arm.

The first cut asked FindProcInUnit and changed nothing at all, which is the measurement that found the real constraint: PyRegisterClassFieldsPrepass runs before PyRegisterDefShells (both call sites order them, and say why), so at field-inference time no Proc exists for a module-level def. Only the tokens can answer, exactly as for a literal global. Hence PyModuleGlobalIsDef, a depth-0 at-statement-start scan for tkFunction followed by the name.

The type is variant with no recorded signature, and that is the existing rule rather than a new one: a LOCAL holding a def is already a variant carrying the callable, and the ctor-PARAMETER arm immediately below answers variant for self.on_next = on_next, where the parameter routinely is a function. It is also the correct answer — the test rebinds h.fn to a different function, which is what a dispatch table does, and a static signature would be wrong the moment it did.

2. The call did not resolve. With the field typed, h.fn(1, 2) was H has no method .fn(). The VARIANT-receiver dispatcher has claimed procedural fields for a while — two passes around PyMakeVariantFieldCall, one for a field with a recorded signature and one for a plain class's variant Callable field — and the STATIC-receiver route (PyParseClassMethodCall) had no such arm. So the receiver the frontend knew more about was the one that failed, the same inversion the ticket this was found under describes for keyword arguments.

Measured before the fix: g = h.fn then g(1, 2) answered 3, while h.fn(1, 2) did not. Reading the field and calling it were each fine; only writing them as one statement failed. The fix reuses PyMakeVariantFieldCall — the same builder, not a second lowering — and fires only when the class really declares the field, so an unknown name still gets the diagnostic and typo detection is unchanged.

Resolution — 2026-08-27

Fixedpoint 7b1b947a22b3, tools/gate.sh quick GREEN. Test: test/test_nilpy_field_holding_a_def.npy + .expected, registered in the Makefile — reading via a local (the control that isolates the store), calling directly, positional and keyword, a second field, a REBOUND field, the ctor-parameter spelling, and self.<field>(...) from inside the class.

Canaries green, named: callable_field_all_shapes, callable_field_call_returns, callable_replaces_its_own_slot, callable_value_defaults, callable_param_heap_callable, a_field_widens_across_methods, class_field_infer_from_ctor, kwarg_overload.

One cell deliberately left and filed: [[bug-n-a-keyword-argument-through-a-procedural-field-needs-a-plain-receiver]] — H().fn(1, b=2) and hs[0].fn(1, b=2) still refuse a KEYWORD argument (a constructor-call or subscript receiver; a call RESULT and a plain local both work, and all four work positionally). That is argument parsing shared by every callable-field call, a different blast radius from field typing, and it wants its own gate rather than riding along.