← board

How it was found

Chasing lekkerzeilen's empty scene. A verbose open-water run printed

session: <__main__.Session object at 0x20eceba8>: not a word this file has

which reads as a wrong value inside session.parse -- a Session object where a line of text belongs. It was not. ~/.local/state/lekkerzeilen/session.conf is 39 bytes and contains exactly that repr: the WRITER was wrong, and parse was correctly reporting the one line of a file it could not read.

session.save ends

    with open(temporary, "w") as handle:
        handle.write(format(it))

and session.py defines its own module-level def format(it) fifty lines above. Under pxx that call reached pylib's format(value), which is str, which is the repr. The diagnostic was CORRECT ABOUT SOMETHING ELSE -- the house failure mode -- and it pointed at the reader for most of an hour.

Reduction

# sess.py
def format(it):
    return "region %s" % it.region

def save(it):
    return format(it)
import sess
print(sess.format(s))   # -> '<__main__.Session object at 0x...>'
print(sess.save(s))     # -> '<__main__.Session object at 0x...>'

Both rows are region rijn under CPython. The same file as the MAIN .npy is correct, which is what localised it to the unit test rather than to format itself.

The fix

compiler/symtab.inc -- new PyShadowDeclHere(idx), used by both PyUserShadowsProc (which decides whether a name-keyed intrinsic stands down) and MatchEligBase's userOnly drop (which decides whether the builtin's overloads stay in the candidate set). It answers

(ProcUnitIdx[idx] = -1) or
  ((ProcUnitIdx[idx] = CurrentUnitIdx) and UnitIsPyModule(ProcUnitIdx[idx]))

-- the main .npy, or a .py module's own defs while THAT module is being parsed. The second conjunct is what keeps sess.format from hijacking a bare format(7.5, ".1f") back in the main program: scoping it to CurrentUnitIdx is the whole point, and a global "any NilPy module declares this name" test would have been wrong in the other direction.

compiler/pyparser.inc -- the format intercept in PyParseFactorCore gained (qUnit < 0). PyUserShadowsProc structurally cannot answer for mod.format(x): it is scoped to the unit being parsed and the caller is a different unit. A qualified name has already said which scope it means, which is the reasoning the neighbouring Ord/Chr arm already spells out.

What it fixes, measured

name defined in the imported module pinned v409 after
format abcd (the builtin) USER
str abcd USER
open FileNotFoundError: 'abcd' USER
abs, max USER USER (unchanged)
len SIGSEGV SIGSEGV (not fixed)
sorted [] [] (not fixed)

The last two rows are split out rather than merged: they go through a different lowering, and a fixture covering all six would let the four green rows certify the two red ones.

Gate

make test-nilpy rc=0; self-host converged; new fixture test/test_nilpy_a_def_in_an_imported_module_shadows_a_builtin.npy (+ test/nilpy_modshadow/sess.py), wired into the Makefile, GREEN at HEAD and RED against the pinned compiler -- the positive control.

Inert until pinned

These are compiler/** and compiler/builtin/pylib.pas changes, so a NilPy program built with ./compiler/pascal26 has them now and a program built with $(PXX_STABLE) does NOT. Anyone rebuilding lekkerzeilen against the pin will still get the blank scene until the next make pin, which is the owner's to run. Saying so here rather than waiting: CLAUDE.md's own rule is that a fix is inert until pinned and that nobody holds a seat waiting for one.

Log