A qualified module member is taken by a case-folded local class
- Type: bug — Track N. Found: 2026-08-27 by agent-A while writing the witness for [[bug-n-importing-both-f-and-F-from-one-module-loses-the-class]]. The row is in that witness, commented out and pointing here.
- Pre-existing: identical at pinned v381 and at HEAD after that fix.
Repro
# cm5.py
def zz():
return 7
import cm5
class ZZ:
VAL = 1
print(cm5.zz()) # CPython 7; pxx: <__main__.ZZ object at 0x...>
Delete the class and it prints 7. Rename the class to anything that does not
case-fold onto zz and it prints 7.
Why it matters
It is silent and it produces a plausible-looking value (an object) where a
number belongs, so it lands in the expensive half of
devdocs/dev/debugging-playbook.md rather than the cheap one. And the qualifier
is explicit at the call site: cm5.zz() names the module in the source, which is
the one spelling a reader would trust.
import utils beside class Utils, import parser beside class Parser are
ordinary Python spellings, and the fold makes them collide.
Family
Same flat-namespace-folds-case root as the fixed sibling, on the other arm. The
sibling's two halves were the ALIAS table (from m import zz registering a
module alias for a function) and the COMPILED-UNIT table (Canvas folding onto
module canvas); both are lookups of the RECEIVER. This one is the reverse: the
receiver resolves correctly and the MEMBER is then taken by a case-folded class.
PyIsExactCtorName is the predicate that should be gating the construction —
it is case-sensitive and would answer False for zz against class ZZ — so the
first thing to measure is whether the qualified path consults it at all. Do not
write a cause into this ticket without measuring: two recent N tickets named the
wrong mechanism and this one's sibling named a third.
Gate
The three lines above matching CPython, plus the commented-out row in
test/test_nilpy_import_case_folds_onto_a_class.npy re-enabled (and its
.expected regenerated by CPython). Per-fix loop; no pin (parser only, unless
the fix reaches compiler/builtin/**).
RESOLVED — 2026-08-27, agent-A
Fixed, same session as the sibling that found it.
Measured
m.zz as a VALUE was always right — it printed a function — and only m.zz()
was taken. That asymmetry is the whole localisation: qualified RESOLUTION works,
so the fault is in whatever decides a qualified call is a CONSTRUCTION.
Root cause — one guard that did not inherit the exactness rule
PyParseFactorCore's qualified-construction intercept (pyparser.inc, the
tk.Frame(root, ...) arm from feature-nilpy-qualified-class-construction) asked
(FindUClassNonRecord(GetTokenStr(TokPos + 1)) >= 0)
FindUClassNonRecord is case-INSENSITIVE, Pascal-style. So zz found class ZZ,
the arm consumed m. and handed the rest to PyClassCreateExpr, and the call
became a construction of a class the program never named at that site.
Its BARE-NAME twin, one screen up in the same function, asks
PyIsExactCtorName — the case-sensitive predicate added by
[[bug-nilpy-a-lowercase-name-is-hijacked-by-a-case-matching-class]] for exactly
this failure. The qualified arm was written afterwards and did not inherit it:
the same one-concept-two-sites shape devdocs/dev/normalise-dont-special-case.md
is about, and the second site is again the one that stayed broken.
The fix
One predicate swap: FindUClassNonRecord(...) >= 0 → PyIsExactCtorName(...).
It wraps the same lookup and adds the exact-spelling test plus class ALIASES
(from m import Box as B registers B against Box's row, so the declared name
never equals the spelling used) — a drop-in that keeps aliased qualified
construction working.
Measured matrix
| shape | before | after |
|---|---|---|
import m + class ZZ → m.zz() |
<ZZ object> |
7 |
same through an alias, import m as cs → cs.zz() |
<ZZ object> |
7 |
m.zz as a VALUE, then called |
correct | correct |
m.Frame(3) — qualified construction, exact name |
correct | correct |
cs.Frame(4) — through an alias |
correct | correct |
the local ZZ.VAL / QQ().VAL the fold was stealing from |
correct | correct |
test_nilpy_qualified_ctor, test_nilpy_qualified_ctor_does_not_capture_its_args
and test_nilpy_shim_from_import_class_attrs — the tests that exist for the arm
being narrowed — were re-run by hand and are unchanged.
The commented-out row in
test/test_nilpy_import_case_folds_onto_a_class.npy (the sibling's witness) is
re-enabled and its .expected regenerated, which is what the Gate asked for.
Gate
make compiler/pascal26 → self-host fixedpoint: verified — 1 round(s).
tools/gate.sh quick → GREEN. Witness
test/test_nilpy_qualified_member_vs_case_folded_class.npy + helper module
registered in test-core, .expected generated by CPython, red at pinned v381
and green now. No pin — parser only.
Log
- 2026-08-27 — resolved, commit 1e3f5d1af.