← board

Why this is worth a ticket rather than a shrug

The warning is good. It is also, on this program, the thing a user would check BEFORE trusting a dynamic property read — and on the one name that actually killed the process it says nothing at all. That is the failure mode we named three separate times today: an instrument whose silence is read as a negative result. This is the compile-time instance of it.

The population, measured

Compiler 934ba0418, lekkerzeilen at the owner's HEAD, built with --threadsafe -dSDL_DISABLE_IMMINTRIN_H -dGL_GLEXT_PROTOTYPES. 122-line build log. Names with two or more @property declarers anywhere in the package:

name declarers warnings emitted
attribution world.py:284, world.py:1060 3
spawn world.py:278, world.py:1064 13
up rig.py:137, sim.py:82 3
water_level world.py:274, world.py:1069 9
bed world.py:316, world.py:1265 0
canopy world.py:339, world.py:1269 0
cover world.py:327, world.py:877 0
flow world.py:344, world.py:1273 0
height ui.py x5 0
length traffic.py:133, traffic.py:409 0
name world.py:270, world.py:1051, traffic.py:425 0
origin world.py:256, world.py:1055 0
size platform/_ctypes_backend.py:47, platform/_pxx.py:714 0
tabbed ui.py:548, ui.py:755 0
value ui.py:239, ui.py:314 0

4 of 15. Several of the silent rows may be silent CORRECTLY — if a name is never read through a receiver whose class is not statically known, there is nothing to warn about. So that table on its own does not establish a bug.

The controlled contrast, which does

Region (world.py:175-375) and World (world.py:963-) both declare EIGHT of these as @property. Same file, same construct, same pair of classes, so everything the table above leaves varying is held fixed:

name Region World warns
water_level 274 1069 YES (9)
spawn 278 1064 YES (13)
attribution 284 1060 YES (3)
origin 256 1055 no
name 270 1051 no
bed 316 1265 no
canopy 339 1269 no
flow 344 1273 no

Three warn, five do not, out of one structurally uniform population.

.flow is not an arbitrary member of the silent five

It is the name from the crash. app.py:1243 is self.env.flow = source.flow if source else None, and app.py:681 passes flow=source.flow if source else None. source is the receiver whose class is not statically known — it is the read that faulted, and when the import order was perturbed it became AttributeError: 'World' object has no attribute 'flow', which is the compiler resolving .flow somewhere World could not satisfy. So this name is dynamically dispatched, has two conflicting property declarers, and is the one the warning does not mention.

water_level is dispatched the same way — region.water_level at app.py:662, 677 and self.region.water_level at 1283, receiver class not statically known — and produces nine warnings.

MECHANISM FOUND — and my own hypothesis 1 was right, killed by a bad instrument

Found by the compiler seat in PyMakeVariantPropRecv (compiler/pyparser.inc), by construction rather than by census. The first loop:

for c := 0 to UClsCount - 1 do
  if (not UClsIsRecord[c]) and (FindUField(c, pname) >= 0) then Exit;

It exits as soon as ANY non-record class declares a real FIELD of that name, which is correct — the caller's field branch owns that access — but it exited with ambig still False, so ambiguity was never computed and the caller neither resolved nor warned. One unrelated field named X anywhere in the program silences the diagnosis for X across the entire program. Constructed pair:

program warnings
two unrelated @property declarers 3
same, plus one unrelated class with self.flow = 99 0
same, after the fix 3

The read/store fork is NOT the silencer: ambig := True is set before it and a read exits with ambig True, so reads do warn.

The correction I owe this ticket

An earlier revision listed "a plain attribute declarer suppresses it" as a hypothesis I had TESTED AND KILLED. It was substantively RIGHT, and I killed it with an instrument that does not measure what it claimed. My counter-example was .up: three textual self.up = assignments (app.py:308, 427, 443) and it warns anyway. But FindUField counts declared fields of a class, and a textual self.X = in a method body is not the same population. The correlation I discarded was real; the refutation was an artifact.

The compiler seat independently hit the identical trap from the other side — a perfect 8/8 correlation between grep -c 'self\.X *=' and my eight rows, then .up falsified it there too. Two seats, same wrong instrument, opposite conclusions drawn from it, and only a CONSTRUCTED two-class pair settled it.

That is the reusable lesson and it is worth more than the bug: a census over real source can only correlate, and a correlation refuted by a miscounted row looks exactly like a correlation refuted by a real one. Build the pair.

What I would want from a fix

Not necessarily more warnings. If the silence on origin, name, bed and canopy is correct because nothing dispatches on them dynamically, then the only wrong row is flow, and the question is narrow: why does a dispatched, two-property-declarer name emit nothing when its sibling in the same class pair emits nine. If instead the warning is missing a whole category of dispatch sites, the blast radius is every silent row, and that is worth knowing before anyone reads a quiet build as a safe one.

Gate

A .npy fixture with two classes each declaring the same name as @property, read through a receiver of unknown class, asserting the warning fires — plus the negative control of a name declared by only one class, asserting it does not. The lekkerzeilen build is the integration check: .flow should warn, or the ticket should record why it correctly should not.

Log