← board

A shim's classes are invisible when two modules import the same shim

Why it matters

Top wall of the ladder: 7 of 48 corpus files, all of tinycss2 + webencodings, through webencodings/x_user_defined.py:50. It is the largest single row in the current table and the only one above 4.

Minimal repro — two files

pk/xud.py:

import codecs
thing = codecs.CodecInfo(name='x', encode=None)

pk/__init__.py:

import codecs                 # <-- REMOVE THIS LINE AND IT COMPILES
from .xud import thing
print("ok")
pascal26 -Fupk -Fupk/pk -Fulib/rtl pk/__init__.py out
  -> pascal26:2: error: undefined variable (CodecInfo)

Delete the importer's import codecs and it compiles. Nothing else changes.

The boundary, measured — most of the obvious suspects are NOT it

Each row varied one thing against the repro above:

shape result
importer does not import the shim OK
importer imports the shim FAILS
relative (from .xud) vs absolute (from xud) no effect
top-level import vs function-local import no effect
imported module calls a shim FUNCTION (codecs.lookup(...)) OK
imported module constructs a shim CLASS FAILS
imported module imports the shim but does not use it OK
two modules both importing an ordinary user module with a class OK

So it is specific to a shim-aliased module (codecs -> mimic_codecs), and specific to its classes. An ordinary duplicate import is fine, which rules out "duplicate import" as the bug on its own.

Where it goes wrong

parser.inc:10014 is the guard that turns mod.Class(...) into a NilPy qualified construction:

(FindUClassNonRecord(GetTokenStr(TokPos + 1)) >= 0) and
(FindSym(CurTok.SVal) < 0) and (FindUnitOrAlias(CurTok.SVal) >= 0)

When it fails the expression falls through to the ordinary path, which is why the diagnostic is undefined variable (CodecInfo) and not class not found — the constructor path at pyparser.inc:6173 is never entered at all. That distinction is the quickest way to confirm you are in the right place.

Of the three conjuncts:

Left to A: whether the fix is at the guard or in the shim/alias registration that should have put mimic_codecs's classes in scope for the second module. I did not narrow past this point because the next step is instrumenting a shared file.

Note on attribution — a done ticket is credited with this row and should not be

The published ladder table ([[bug-t-the-ladder-scan-passes-only-one-root-so-cross-package-imports-read-as-walls]]) attributes the CodecInfo row (7 files) to [[bug-n-a-temporary-receiver-resolves-to-the-shim-type-not-the-user-class]]. That ticket is done and inside pin v347, and the row is unchanged at 7 on both the pinned and HEAD scans — so it cannot be the cause. Different mechanism: that one was a qualifier leaking into a nested construction's arguments; this one is the qualified construction never being recognised. The attribution should be corrected wherever it is cited, or the next reader will treat this wall as already fixed.

FIXED 2026-08-18 (frank2-7e, combined A+N) — the uses edge, not the guard

The trigger, refined — the shape of the repro is load-bearing

The title's "two modules import the same shim" is necessary but not sufficient, and the weaker reading is what made this look non-reproducible when a peer first checked it. Measured row by row:

shape pre-fix
other module imports the shim, uses nothing resolves
other module uses a shim PROC (module level or in a function) resolves
other module uses a shim CLASS FAILS
other module uses the class, entry module uses only a PROC FAILS
the class used in the ENTRY module alone resolves

So: the entry module's import is what marks the shim's SPELLING compiled, and the defect then bites whichever OTHER module uses the shim's class. A repro where the entry module uses the class passes while the bug stands, because the entry module is exactly where the real edge did get recorded.

That is the corpus shape precisely, and it explains the row size in one sentence: webencodings/x_user_defined.py does class Codec(codecs.Codec) at module level, and __init__.py imports it — one imported sibling subclassing a shim class, behind 8 files.

Root cause — measured, and it overturned my own filed hypothesis

I filed this with FindUClassNonRecord as a by-elimination guess and a VisibilityAllows chain derived by READING the source. A PXXDBG a.qual probe at the guard printed the actual state:

control  curname=xud unitname=mimic_codecs clsunit=615 nonrec=131 visible=TRUE
repro    curname=xud unitname=mimic_codecs clsunit=613 nonrec=-1  visible=FALSE

Both arrangements resolve the qualifier to mimic_codecs and find the class there via FindUClassInUnit. The only difference is visibility — which proves the class row was never missing and kills the guard-level reading outright.

ParseUsesUnit records the uses edge against strIdx, the spelling (codecs), and guardIdx is that same spelling. On the FIRST import that is harmless: the shim branch re-enters through ParseUsesUnit('mimic_' + lo), which records the real edge to mimic_codecs on the way in. A SECOND importer never reaches that branch — the spelling is already in CompiledUnits, so it takes if isCompiled then Exit (parser.inc:33518) holding an edge to a name nothing is declared under. DeclVisible then fails and FindUClassNonRecord skips the row.

The proc/class asymmetry is a qualified/flat split: a qualified PROC names its unit and never consults the visibility-filtered scan. The surviving procs were not special — they simply never went through DeclVisible.

The fix, and the fix that was NOT taken

On the already-compiled exit, resolve the spelling through the alias chain and record the edge to the REAL unit.

The tempting alternative was to make the guard at parser.inc:10014 ask FindUClassInUnit the way the ctor path at pyparser.inc:6173 already does. It would have compiled the repro and left every other visibility-filtered lookup in a second importer broken — a passing test certifying a resolution hole. Only the probe separated them.

This is the same defect and the same fix as the duplicate-.py arm ~700 lines below (search pyDupIdx), whose comment already recorded the answer the first time round: an alias answers a QUALIFIER, it does not make a module's symbols VISIBLE. Two arms of one concept where one already knew — so this deletes a case rather than adding a mechanism.

Corpus effect — reported past-vs-onto, and the compile count did NOT move

Clean A/B: only this commit touches compiler/** since pin v348, so pinned-vs-HEAD isolates it exactly. Same corpus, same lib/rtl, -Fu both roots, tools/nilpy_ladder.py.

v348 (control) HEAD + fix
compile 6/48 6/48 — unchanged
undefined variable (CodecInfo) 8 0 — gone from the table
undefined variable (yield) 3 11

Every other row is identical, and 3 + 8 = 11: the eight files moved wall-to-wall.

The top wall is cleared and not one additional file compiles. That is the honest headline. This clears a wall; it does not open the pipeline — the compounding recorded in [[feature-nilpy-yield-outside-a-for-loop]], now demonstrated rather than predicted. yield is now the largest row on the board by nearly 3x, which is the argument for scheduling it alongside the module shims rather than behind them.

Regression test

test/test_nilpy_shim_class_in_imported_module.npy + test/nilpy_units/shimclassuser.npy, wired into test-nilpy (which enumerates and never globs, so it is named explicitly).

Deliberately built in the strong shape — class used in the imported module, entry module touching only a proc — because the weak shape passes while the bug stands. Verified both ways: fails on pinned v348 with undefined variable (CodecInfo), passes at HEAD.

Gate

make compiler/pascal26 (fixedpoint, converged) + the 7-row boundary table + the 4 corpus files + the new test failing pre-fix and passing post-fix + tools/gate.sh quick GREEN. No pin needed — parser.inc only, nothing in compiler/builtin/**.

Log