← board

An exact-case match in an outer scope beats a case-insensitive one in a nearer scope

Measured 2026-09-06 at compiler b50b1643e1a8 against fpc 3.2.2. Found while probing an unrelated accessor ablation; no ticket led here.

program shadow4;
var counter: LongInt; ok: LongInt;
procedure Bump(Counter: Integer);
begin
  counter := 55;      { the lower-case spelling of the PARAMETER Counter }
  ok := Counter;      { must read 55 }
end;
begin
  counter := -1; ok := 0;
  Bump(7);
  WriteLn('global counter=', counter, ' seen=', ok);
end.
global counter ok (the parameter)
pxx 55 7
fpc 3.2.2 -1 55

Both halves are wrong and they are wrong in opposite directions: the global is corrupted and the parameter keeps its old value. Silent — no diagnostic, and --strict-case does not fire on it either (measured: the program above compiles clean under the flag).

The boundary, which is what names the mechanism

shape pxx fpc
same-case reference to a parameter parameter parameter
different-case reference to a parameter outer parameter
different-case reference to a LOCAL outer local
Inc(counter) on a parameter Counter increments the outer increments the parameter
different-case, no exact-case match anywhere (global COUNTER, local Counter, reference counter) local local

The last row is the control. It is the case where the exact pass finds nothing, so the case-insensitive pass runs and the ordinary innermost-first walk gives the right answer — which is why this is not "pxx folds case wrong" but specifically "pxx ranks case-exactness above scope depth".

A for I := 1 to 3 over a local i also works, for the same reason: no outer i exists to win the exact pass.

Cause

FindSym (compiler/symtab.inc, ~4827) walks the hash chain twice:

  1. StrEqual (exact) over the whole chain, and
  2. CaseEqual over the whole chain, for not SymCaseSensitive[i] symbols.

Its own comment says what it means to do and does not:

Exact-case match first, innermost scope out (preserves shadowing). ... Hash chain is NEWEST-first, so walking it is the linear downto order.

Innermost-first is true WITHIN a pass. Across the two passes it is false, and shadowing is exactly what the structure gives up: an exact match in the outermost scope beats a case-insensitive match in the innermost one. Per CLAUDE.md's comment-vs-code rule, one of the two is wrong and here it is the code — the comment states the Pascal rule correctly.

Why the obvious one-pass fix is not the fix

Merging the passes into exact or (CaseEqual and not SymCaseSensitive[i]) lets chain order alone decide, and chain order is DECLARATION order, not scope depth. That is right for Pascal and wrong for NilPy, where SymCaseSensitive exists precisely because Foo and foo are two different symbols in one scope and the exact one must win regardless of which was declared later.

The shape that satisfies both is to make the passes per enclosing block, innermost outward — exact then case-insensitive within each block before moving out — rather than exact-everywhere then insensitive-everywhere. That is a restructure of the walk, which is why this is a ticket and not a microfix.

Gate

The five boundary rows above as one fixture, byte-identical to fpc; plus tools/run_fgl_corpus.sh and the conformance suite, because this changes name resolution for every program and the failure mode is a silently different symbol rather than an error.

Neighbour

[[bug-p-strict-visibility-is-silent-on-records]] is the other case of a resolution-time rule that is opt-in and covers less than its name suggests.

Fixed 2026-09-06 (frankB), compiler 6a676f92b94d

FindSym is ONE walk now:

    if (StrEqual(Syms[i].Name, lo) or
        ((not SymCaseSensitive[i]) and CaseEqual(Syms[i].Name, lo)))
       and IsBlockVisible(SymBlockId[i], CurBlockId) and SymBindableHere(i) then

The second walk is DELETED rather than kept: its predicate is strictly implied by this one's second disjunct under identical gates, so it could not fire. The four copies of the gate expression became SymBindableHere — they were only ever correct identical, and the decl-order arm alone reads five columns.

The shape this ticket proposed is wrong, and it was measured wrong

The body above says the fix is "per enclosing block, innermost outward". It is not, because SymBlockId does not model routine scope: the only writer of CurBlockId is ParseBlockAST, which allocates a block per begin ... end and only when --lazy-var is on. A routine's parameters and locals are registered with the ENCLOSING block's id, so a parameter and a program global sit in the same block and a per-block walk changes nothing. Built and run: the fixture still failed 7 rows.

What actually makes the single walk correct is SymRollbackTo. It UNHASHES every symbol a routine declared when that routine exits, so the chain contains only symbols genuinely in scope — and since inner scopes register after the outer ones they shadow, the chain's newest-first order IS scope depth. That is the fact the old comment was reaching for, and it lives one function away from the walk that depends on it.

NilPy was the right fear and the wrong diagnosis

The obvious merge was tried first and broke uforth (pyeval: invalid assignment target), which read as "the two walks were protecting SymCaseSensitive". They were not. A temporary print of every binding the new disjunct changed named the whole population in one compile — two names:

NEWBIND Cur -> cur  kind=skLocal  line=4453 4463 4464 4482 4500
NEWBIND Src -> src  kind=skParam  line=2907 2926

Both in compiler/builtin/pyeval.pas, and both REAL Pascal collisions: DoAssignment declares a local cur: Variant and then reads TkText[Cur] meaning the unit's Cur: Integer token cursor; pyclosure_src_new(const params, src) saves and restores the unit's Src: AnsiString as sSrc := Src, which under correct scoping is the PARAMETER — so it restored the interpreter's source buffer to the closure's body text. Renamed to curV and srcText; uforth then ran under the merged walk too, and the narrowed Kind <> skGlobal variant that had been written to dodge the problem was dropped as an unnecessary special case.

What the old order was masking, and it is the reason this change is bigger than its diff

A corpus-wide sweep with that instrumentation — every Pascal source under the test and examples trees — flagged 13 files, of which the fixture itself is one:

Neither file was made wrong by this fix. Both were already wrong and the old walk order was the only thing making them work.

Gate

gate.sh quick GREEN including the FPC seed canary; run_fgl_corpus.sh 7/7; uforth compiles and evaluates (3 3); the five corpus tests whose bindings changed and the two lib/rtl/strutils tests all GREEN; and the neighbour Makefile job either side of the new row was run, not just the new row.

Follow-on, not done here

pxx should REFUSE a parameter and a local that differ only in case, the way fpc does — that is what would have caught strutils.pas at its declaration instead of at a name lookup three functions later. Filed as [[bug-p-a-parameter-and-a-local-that-differ-only-in-case-are-two-symbols]]; not landed with this change because a refusal is a narrowing over a population nobody has enumerated, and this commit already moves name resolution.

Log