← board

Redefining a def rebinds the calls that came before it

Repro

def q(a):
    return "first:" + str(a)
print(q(1))            # CPython first:1     pxx second:1
def q(a):
    return "second:" + str(a)
print(q(2))            # CPython second:2    pxx second:2

No diagnostic. The name resolves once, statically, to the LAST definition in the module, so every call site — including the ones lexically above the redefinition — targets it.

Why it is worse than it reads

With the same signature it is a wrong VALUE. With a different signature it is memory corruption: the case that surfaced it had

def g(x, *rest):
    return str(x) + "|" + str(rest)
print(g(1, *xs))       # bound, at run time, to the LATER g...

def g(x, *rest):
    return x, rest     # ...which returns a TUPLE, not a str

and the earlier call — compiled expecting a string result — printed several kilobytes of raw memory. So the failure mode is not bounded by "you get the other function's answer".

In scope under the upward-compatibility rule

Redefinition is ordinary CPython that runs to completion and observably differs, so this is not the "laxer than CPython is a feature" case (devdocs/dev/nilpy-semantics-divergences.md). It is the mirror of [[project_nilpy_trial_parse_rolled_back_symbol_index_recycled]]'s family: one NAME, two bindings, and the frontend keeps only the last.

Shape a fix probably takes

NilPy names are resolved statically, so the fix is at DEFINITION time, not call time: a second def of an existing name should allocate a NEW proc and rebind the name from that point in the parse forward, leaving already-parsed call sites pointing at the first. That is how the shadowing rules elsewhere in this frontend work, so the machinery is likely present.

Worth checking in the same pass whether a class redefinition, and a def that shadows an imported name, have the same shape.

Gate

.npy diffed against CPython: same-signature redefinition with calls on both sides; different return TYPE (the corrupting case above); a redefinition inside a branch that does not execute; and a class redefined the same way. Per-fix loop.

2026-08-15 — mechanism located, PARKED (not started)

Found the exact site and, more usefully, found that this is the overshoot of a shipped fix for the opposite bug. That changes what a safe fix looks like, so it is recorded before anyone starts.

PyParseDef (compiler/pyparser.inc ~26072):

procIdx := FindProcInUnit(name, -1);
if procIdx < 0 then procIdx := FindProc(name);
if (procIdx >= 0) and (Procs[procIdx].BodyAddr >= 0) and
   (Procs[procIdx].ParamCount = nparams) then
begin
  { ...overwrite the FIRST proc's signature, and later its body... }

A same-arity redefinition deliberately reuses the first def's Proc. Its own comment says why, and cites the ticket it fixed:

a second same-arity Proc is one no call site can ever reach, and every call kept running the FIRST body forever (bug-nilpy-redefining-a-def-is-ignored-the-first-body-still-runs)

So the two failure modes are the two ends of one lever: give the redefinition its own Proc and no call reaches it; reuse the first Proc and every call reaches the second body, including the ones written above it. Both are wrong, and a fix that only moves the lever back re-breaks the shipped ticket. That is the whole reason this is parked rather than attempted.

What CPython actually does is neither: def is an assignment executed where it stands, so the binding is positional — calls above see the first body, calls below the second. Two Procs AND a rebinding at the def's position.

The constraint that makes it non-trivial: PyRegisterDefShells is a PRE-PASS that registers every module-level def before any body is parsed, and FindProc's hash chain answers the OLDEST registration for a name. So "which registration is current" cannot be read off the symbol table at all — it needs a per-name cursor advanced as the main parse passes each def statement, which call-site resolution then consults. That is a change to NilPy name resolution, in the one spot with a documented history of regressing in both directions (the same block also carries a def len(x) builtin-shadowing fix).

Do not take this as a between-tasks item. It wants the full sibling set green in one go: redefinition with calls on both sides, differing arity (the existing separate-Proc path, which must keep working), a def shadowing a pylib builtin, and a nested def's qualified name.

2026-08-16 — the silence is fixed; the binding is designed, not built

Two things, deliberately separated.

Landed: it now SAYS SO

PyParseDef's same-arity reuse branch warns, naming the redefinition's line:

pascal26:5: warning: Nil Python: `q` is defined again here; calls written ABOVE
this line will run THIS body, not the earlier one (CPython binds them to the
earlier one). Rename one of the two if that is not what you meant

Gated on NilPyUserCode and on the proc carrying a ProcPyDefTok (i.e. it is a module-level def the shell pass registered), so a class method and every Pascal path are untouched. This does NOT fix the binding — the values are still CPython-divergent — but the failure mode was "several kilobytes of raw memory with no diagnostic", and a message naming both definitions is the difference between a debuggable program and a haunted one. Self-host byte-identical, gate.sh quick GREEN.

Designed, NOT built: the actual fix

Worked out far enough to name the hazards, then stopped on this ticket's own advice. Three changes, and they only work together:

  1. A Proc per DEF STATEMENT. PyRegisterDefShells currently registers one shell per NAME (if FindProcInUnit(PyHdrName, -1) < 0). Register one per def token instead — ProcPyDefTok already records where each stands, and the pre-pass already visits each def exactly once.
  2. PyParseDef picks its shell by DEF TOKEN, not by name. With one Proc per def the same-arity overwrite branch becomes dead for module-level defs and must stay only for the nested/class defs the pre-pass does not register.
  3. Positional preference in FindProcInUnit's own-scope arm, split by where the call is: at MODULE level (CurProc < 0) prefer the last candidate whose ProcPyDefTok <= TokPos; inside a def BODY prefer the highest ProcPyDefTok outright — because that body runs later, and CPython binds it then. Today's accidental behaviour is right for the second case and wrong for the first, which is why a naive "first wins" flip re-breaks [[bug-nilpy-redefining-a-def-is-ignored-the-first-body-still-runs]].

The two hazards that stopped it, both in step 3, both in a block whose own comments record having broken self-host before:

So it needs the full sibling set green in one go — redefinition with calls on both sides, differing arity, a def shadowing a pylib builtin, a nested def's qualified name — exactly as the section above already said. Still not a between-tasks item; it is now a designed one.

Resolution — 2026-08-27

The three-step design above was implemented as written. Fixedpoint b2f0cd61af06, tools/gate.sh quick GREEN.

1. One shell per DEF, not per name (compiler/pyparser.inc, PyRegisterDefShells). The if FindProcInUnit(PyHdrName, -1) < 0 then guard is gone; each def token now gets its own Proc. That guard WAS the bug: two module-level def f shared one shell, so they were literally the same routine and the earlier call site had nothing else to resolve to.

2. PyParseDef picks its shell by def token, via a new PyShellForDefTok (name + ProcUnitIdx = -1 + exact ProcPyDefTok). Without this, step 1 breaks the build outright — FindProcInUnit(name, -1) returns the FIRST shell for both defs, so the second's shell never receives a body and the link fails with unresolved forward. The Warn('... is defined again here') block was deleted: a redefinition is now correct Python, not a diagnostic.

3. Positional preference (compiler/symtab.inc). New PyDefPosBeats(cand, cur) gates on same arity — so the different-arity overload path (def min(a, b, c, d, e) over the builtin) keeps its existing ranking untouched — and then splits by call site: inside a def body (CurProc >= 0) the highest ProcPyDefTok wins outright, because that body runs later and CPython binds it then; at module level only candidates standing at or before TokPos are eligible. Both of FindProc's own-scope picks call it. MatchEligBase drops a candidate that PyDefSupersededHere finds a later same-arity bound def for.

The bug inside the fix, which cost the most time here. Step 3 had NO effect at first, and reasoning about why produced nothing — a WriteLn(StdErr) probe in PyDefSupersededHere showed only ONE candidate was ever tested. Cause: ProcPyDefTok was zero-based and the FIRST def in a file IS token 0, which collided with the array's own "not a def" sentinel. So the first def in every NilPy file was invisible to every reader of that array — including the two pre-existing ones, PyDefBoundHere and PyUserShadowsProc. It is now ONE-BASED, documented at the declaration in compiler/defs.inc, with all readers adjusted. Measure, do not reason: the probe found in one run what the reading had not.

Test: test/test_nilpy_def_redefined_rebinds_only_after.npy + .expected (CPython-generated), registered in the Makefile beside test_nilpy_nested_def_redefined_in_one_scope — the same bug one scope down. 14 rows, all matching CPython, covering every item the ticket demanded: calls on both sides of a same-signature redefinition; differing return TYPE; differing ARITY; three defs so the middle one is neither first nor last; a redefined pylib-builtin shadow; a nested def; and a call from inside another def.

The pinned v384 binary fails 5 of those 14 rows, including the corrupting one this ticket was named for: g-before printed 135566536474856 — the later def's string pointer read as an integer through the earlier def's type.

Canaries run (all green, all diffed against CPython or their .expected): test_nilpy_nested_def_redefined_in_one_scope, test_nilpy_def_shadows_builtin_positionally, test_nilpy_def_shadows_pascal_intrinsic, test_nilpy_builtin_shadow_slice, test_nilpy_cast_user_shadow, test_nilpy_def_local_shadows_module_global, test_nilpy_redefine_def, test_nilpy_intrinsic_result_chain. The first two are the ones the ticket named as must-not-regress, since the positional builtin-shadowing rule shares ProcPyDefTok with this change.

Deliberately out of scope, both measured identical on pinned v384 and therefore pre-existing, both appended to [[bug-n-a-module-level-rebinding-still-loses-to-a-def-of-the-same-name]]: f = lambda: 2 after def f (no token position on a module-level assignment), and a def inside a taken if True: branch (PyRegisterDefShells walks depth 0 only). The second carries a Track U question — what a conditionally-bound name should resolve to — and is noted as such rather than guessed at.

Log