← board

A @property setter is skipped when the receiver is dynamically typed

25-line repro — line 2 agrees with CPython, line 3 does not:

class VM:
    def __init__(self):
        self._c = False
        self.log = []

    @property
    def compiling(self) -> bool:
        return self._c

    @compiling.setter
    def compiling(self, value: bool) -> None:
        self._c = value
        self.log.append(value)

vm = VM()
print("start", vm.compiling)
vm.compiling = True
print("after direct", vm.compiling, vm._c, vm.log)   # both: True True [True]

def setit(v):            # v is dynamically typed
    v.compiling = True

vm2 = VM()
setit(vm2)
print("after fn", vm2.compiling, vm2._c, vm2.log)
#   CPython: True True [True]
#   pxx    : False False []

_c is untouched and log is empty, so the setter did not merely write the wrong place — the store vanished. No warning, no error. The getter still works on both paths, which is what makes it look like a read bug.

Two paths, one concept — the usual shape

The property-setter store is implemented on the STATICALLY-typed receiver path only. A receiver whose class the frontend has not resolved (a plain def f(v) parameter, a variant slot, a container element) takes the other path, which stores a plain field — and for a property there is no such field, so the write goes nowhere. Compare [[project_nilpy_lvalue_vs_selector_path_must_both_know]]-style splits: teach one member-access path and the sibling stays broken.

Note the ASYMMETRY: the getter is honoured on the dynamic path, the setter is not. That is what makes the failure read as "the flag never got set" rather than "the store was dropped".

PRE-EXISTING

Reproduced identically under stable_linux_amd64/default/pinned. Nothing to do with the Callable-field or -> None ABI work of 2026-08-08; it was simply unreachable in uforth until the segfault ahead of it was cleared.

This is what blocks uforth

uforth.py maps its Forth STATE flag onto memory through exactly this pattern:

@property
def compiling(self) -> bool: return self._compiling
@compiling.setter
def compiling(self, value: bool) -> None:
    self._compiling = value
    self.memory[SYS_STATE_ADDR:SYS_STATE_ADDR + 8] = ...

w_colon (the : word) is a lifted nested def taking vm as a normalised VARIANT parameter, so its vm.compiling = True is dropped. Every colon definition therefore stays in interpret mode and its body EXECUTES instead of compiling. Observable at uforth's own prompt:

: X 1 ; 5 X .        CPython: 1        pxx: 5     (X's body is empty)
.S                   CPython: <empty>  pxx: 13 junk entries — the PYTHON word
                                       bodies from CORE.UFO, pushed as literals

and STD.UFO dies at CORE.UFO:80 with "expected a number, got str", which is the RTL coercing one of those stray strings. One cause, all three symptoms.

Gate

The repro above oracle-diffed with tools/pydiff.py, extended to cover the other dynamic receivers (variant local, list element, self inside a method reached as a bound value), plus make test-uforth getting past STD.UFO, plus the per-fix loop. A property setter that cannot be reached should be a DIAGNOSTIC, never a dropped store — if some receiver shape genuinely cannot dispatch it, say so at compile time.

RESOLVED 2026-08-08 — the reported bug was one of THREE, and not the deepest

The dynamic receiver was the symptom that got filed. Narrowing it against the CPython oracle turned up two more silent property-write losses in the same concept, one of which is the actual reason uforth still failed after the first fix was in and measured working.

1. A dynamic receiver never reached the property machinery — parser.inc

ParseLValueAST's variant-receiver branch resolved a member by FIELD name only (PyMakeVariantField), and a property has no field, so the access fell through to the dynamic-attribute store: the write landed in pydynattr's side table while the getter went on reading the backing field. New PyMakeVariantPropRecv (pyparser.inc) unboxes the slot and hard-casts it to the declaring class, and control then FALLS THROUGH to the class-typed property path — deliberately not a second implementation, since a second one is what this bug is. Candidate choice mirrors the method scan: a base and its subclasses are not ambiguous (a virtual accessor still dispatches), two unrelated classes are a diagnostic rather than a guess.

2. if not self.prop: declared a phantom FIELD that shadowed the property

This is what kept uforth broken after fix 1. The member pre-pass reads self.NAME : as an annotated field declaration — but the colon that ENDS a compound-statement header has the same three tokens before it, so if not self.compiling: declared a variant field named compiling on the class that also declares the property. Harmless for a real field (already registered, the duplicate guard skips it); fatal for a property, which it then shadowed everywhere. while self.x:, {self.x: 1} and a[self.x:] are the same shape. An annotated declaration is a STATEMENT, so the scan now requires the self to START one. The self.x = ... arm is untouched (chained assignment would break).

This one is why the ticket's own repro was not enough: it had no self.prop read inside a method, so fix 1 made it pass while uforth — which has three — stayed exactly as broken. The repro that passes is not the program that fails; PXXDBG on AddUField is what closed the gap, after reading the source had not.

3. obj.prop += 1 was dropped on EVERY receiver, static included

Found by the four-shape gate matrix, not by the ticket. The property path tested only for :=, so an augmented assignment took the READ arm: the getter ran, its RESULT was handed to the assignment machinery, and the store went nowhere. Desugared to set(get() OP rhs) through the property's own accessors, so an override still dispatches through the VMT. Field-backed and indexed properties keep their existing paths (the first is already correct; the second would evaluate the index twice).

Verified

test/test_nilpy_property.npy EXTENDED rather than duplicated — it already existed and covered only the statically-typed receiver. Now 8 lines across static receiver, untyped parameter, variant local, list element, dict value, subclass, augmented assignment on both receiver kinds, the if not self.prop: shadow trigger, and a genuine self.depth: int = 3 proving the narrowed scan still registers what it is for. Gated by .expected diff instead of the inline printf.

It DISCRIMINATES — stable_linux_amd64/default/pinned fails 4 of the 8 lines:

-static aug 22                              +static aug 2
-param compile True 31 True [True]          +param interpret False 10 False []
-listelem 7 7 compile compile               +listelem 7 7 interpret interpret
-dictval 5 5                                +dictval 5 10

make compiler/pascal26 byte-identical (converged in 1 round) · tools/gate.sh quick GREEN.

uforth: advanced again, still red, new blocker

vm.compiling = True now takes effect and colon definitions compile instead of executing. make test-uforth fails further on, in a different component: pyeval: host method define_word has an unsupported param shape — filed as [[bug-nilpy-pyeval-host-call-refuses-a-mixed-variant-and-scalar-param-shape]], which [[bug-nilpy-uforth-compiles-but-segfaults-at-runtime]] now blocks on.

Log