← board

A NilPy name resolves case-insensitively against a builtin

print(Counter)     # CPython: NameError: name 'Counter' is not defined
                   # pxx:     4727019          (a code address)

Measured 2026-08-15 while triaging [[regression-test-core-test-nilpy-forward-module-global]], where this was the substrate the regression stood on: FindProc('counter') answered pylib's Counter, so a helper that meant "is there a nested def of this name" got True for an ordinary module global and every read of it became a function value.

Why it happens

FindProc lowercases — Pascal's rule, and correct for the Pascal frontend sharing these tables. NilPy inherits the lookup. Python is case-sensitive, and the case difference is often exactly what distinguishes a class from a variable (Counter the type vs counter the tally), so the collision lands on the naming convention Python programmers actually use.

Scope, measured

Reads of a name that IS bound are safe today: the symbol lookup wins first, so counter = 7; print(counter) and a def reading it are both right. What is exposed is a name with no binding — CPython's NameError — which answers a builtin of a different case instead. NilPy is upward-compatible with CPython, so a program that RUNS on CPython cannot observe this; a program with the bug CPython would have diagnosed gets a silent wrong value instead of a diagnostic, which is the loss.

The shape a fix probably takes

The NilPy-facing lookups need a case-SENSITIVE variant, not a global change to FindProc (the Pascal frontend depends on the insensitive one, and the tables are shared). Candidates: PyMakeFuncValue, PyUserShadowsProc, the callable-value arms in ParseFactor, and PyNestedDefOutranksSym. Check whether the registered name's own spelling is available to compare against — if the proc table keeps the source spelling, this is a comparison, not a new index.

Related to the "own language first" rule: a NilPy name should see NilPy's namespace before anything the shared table happens to hold.

Gate

.npy diffed against CPython: print(Counter) raising NameError; a global named counter read from a def, from a nested def and from a comprehension; counter as a parameter name; and a user def named counter still callable.

2026-08-15 — half landed, and the remaining route MEASURED

FindProcExactCase added to symtab.incFindProc filtered to an exact-case result. Written on top of the real lookup rather than beside it: FindProc's exact pass runs FIRST and its case-insensitive fallback only after, so a result whose stored name differs in case can only have come from the fallback. One line, and it cannot drift from the visibility / require-forward / unit rules.

Applied to the two NilPy VALUE-position arms — PyMakeFuncValue and ParseFactor's callable-value arm. Measured effect:

x = LEN          # was: a code address.  now: "undefined variable (LEN)"
f = len          # unchanged, still the function value

The remaining route is the CALL path, and it is a bigger change than this one. A bare name that resolves as a zero-argument call still matches case-insensitively:

print(cOUNTER)   # prints {} — it CALLED pylib's Counter()

That resolution is the shared Pascal name→proc path in ParseFactor, not a NilPy arm, so making it exact-case for .npy only means threading the dialect through the shared lookup (or registering pylib's NilPy-facing procs case-sensitively via the ProcCaseSensitive[] flag that already exists — which is probably the better shape, since it says the thing once at registration instead of at every lookup).

Not attempted here: ProcCaseSensitive is read by the Pascal side too, and flipping it for pylib changes what every Pascal uses pylib program resolves. That is a Track A-shaped decision, not a NilPy-arm edit.

Also worth recording, because it wasted a build: function F(...): Integer; written in the forward block WITHOUT forward; does not fail as a syntax error — every routine after it becomes NESTED inside it, and the failure surfaces minutes later as "nested routine token buffer overflow" naming a routine hundreds of lines away.

Back to the backlog with the diagnosis, per root-cause-over-microfix.

2026-08-15 — the CALL path landed too; closing

The parked note said the remaining route needed either "threading the dialect through the shared lookup" or "registering pylib's procs case-sensitively", and rejected the second because ProcCaseSensitive is read by the Pascal side. Neither was needed. The predicate the frontend already has is NilPyUserCode (symtab.inc), the same one DeclCaseSensitive keys on — so the split is drawn once, at the one bare-name resolution site, without touching registration:

if NilPyUserCode then procIdx := FindProcNilPyBound(name)
else procIdx := FindProcBound(name);

FindProcNilPyBound is FindProcBound minus the case-insensitive fallback where it reaches Python's own namespace — a non-exact hit whose ProcUnitIdx names pylib or pyeval answers -1. Scoped that way on purpose: the lax rule is what keeps a Pascal RTL routine reachable from NilPy under whatever case it is spelled with, which DeclCaseSensitive's own comment calls out as deliberate, and only pylib rows are Python's builtins.

Measured, HEAD vs pinned (the control), print(<name>):

source pinned now CPython
cOUNTER {} — it CALLED Counter() refused NameError
Counter code address code address NameError
counter = 3; counter 3 3 3
len / print / str unchanged unchanged

Swept the mangled-case call forms: SORTED, SUM, Len, Max, REVERSED, Range, Zip, Enumerate all refused now.

Two residues, both deliberately left. Counter bare still answers pylib's Counter because pxx puts collections.Counter in the builtin namespace where CPython requires an import — accepting what CPython rejects, which the upward-compatibility rule calls a feature, not a defect. And ABS(-2) / Round(1.5) / Chr(65) / ORD('a') still work: those are Pascal SYSTEM intrinsics, not pylib rows, and they answer the correspondingly named function — a lax spelling of a right answer, never the wrong-value collision this ticket is about.

Test: test/test_nilpy_case_sensitive.npy extended with a global counter read bare, from a def, from a nested def and from a comprehension, counter as a parameter name, and a user def named counter_fn — byte-identical to CPython. Gate: gate.sh quick GREEN + self-host fixedpoint; no pin (nothing under compiler/builtin/**).

Log