Routine-local name scoping is implemented in one of FIVE tables, and the one implementation is wrong at the edge
Six measured repros, one concept, five name tables. Every one is a silent wrong value or a refused-correct-program, and none needs generics.
| table | rule today | measured |
|---|---|---|
| UCls (record/class type names) | FLAT, first match wins | a SIBLING routine's local TRec loses to an earlier routine's — fpc A 3 / B 1, pxx A 3 / B 3. The row the title's own repro needs. |
| alias (type aliases) | FLAT, first match wins | nested routine's TRec ignored, binds the enclosing one — fpc 1, pxx 3 |
| set const | FLAT, first match wins | sibling routine's S = [7,8] ignored, binds the earlier routine's S = [1,2,3] — fpc B FALSE TRUE, pxx B TRUE FALSE |
| string const | two-tier: own routine, else global | over-corrects — a nested routine reading the ENCLOSING routine's const is refused: undefined variable (Greeting), fpc prints it |
procedure A; const S = [1, 2, 3]; var v: set of Byte;
begin v := S; Writeln('A ', 3 in v); end;
procedure B; const S = [7, 8]; var v: set of Byte;
begin v := S; Writeln('B ', 3 in v, ' ', 7 in v); end; { pxx: B TRUE FALSE }
begin A; B; end.
procedure Outer; const Greeting = 'from outer';
procedure Inner; begin Writeln(Greeting); end; { pxx: undefined variable }
begin Inner; end;
This is the sibling grep that was never run
StrConstOwner (defs.inc) exists for this exact bug, one table over —
bug-pascal-string-const-not-scoped — and its comment describes today's alias
and set-const behaviour word for word: "a const S = 'x'; inside one routine
stayed visible to every routine parsed after it — and the lookup returned the
FIRST match, so the leaked one even beat a later routine's OWN const of the same
name, substituting the wrong text silently." The rule was written once and the
other two tables were never checked. Three mechanisms for one concept is the
count devdocs/dev/root-cause-over-microfix.md calls a design flaw, and
normalise-dont-special-case.md's "fixed one arm, grep for the sibling" is the
step that was skipped — so fixing FindSetConst alone would repeat it.
Why the existing rule cannot just be copied
FindStrConst's two tiers are own routine and global, with no tier for an
ENCLOSING routine — which is the third row above. There is no proc parent chain
to walk: ParseNestedRoutine lambda-LIFTS a nested routine into a flat
top-level one (Approach B, PendNestTok / FlushPendingNestedProcs), so by the
time Inner is parsed its CurProc is its own and the enclosing routine is
gone from the parser's state. The alias table's flat search gets that row right
BY ACCIDENT, which is why copying the "fix" onto it would trade a wrong answer
for a refusal.
The fix is one lift-parent recorded per lifted routine (the per-routine
boundaries in PendNestRtnStart are already the place to hang it) and ONE
ScopeReaches(ownerProc, CurProc) helper walking it, used by all three
lookups — and by AliasCommit, which is already the single chokepoint the alias
table added for exactly this class of "the rule was spelled at one call site and
missing at another".
Not to be confused with
bug-p-a-nested-routines-local-type-does-not-shadow-the-enclosing-routines is
the alias row of this table, filed first and narrower; this ticket is the group.
Fix them together or the halves disagree.
Noticed in passing, not measured further: the Greeting refusal's diagnostic
names ./compiler/builtin/builtinheap.pas as the file, which is not where the
error is — the token->file map does not follow a lifted routine.
CORRECTION 2026-09-06: it is FIVE tables, and the title undercounts
Measured while fixing it. UCls is the fifth and it is the one the headline
repro actually needs: type TRec = record ... end inside a routine is a UCls
row, not an alias row, so AliasOwnerProc does not reach it and the two are
indistinguishable in the source. FindUClass's same-unit scan took the FIRST
match, so a SIBLING routine's local TRec beat its own — fpc A 3 / B 1, pxx
A 3 / B 3, no nesting and no generics. Syms is the control: already scoped
via FindSym, which is what shows the rule is writable here.
And "no proc parent chain exists, so the lift-parent must be recorded at lift
time" — written above, and false. ParseNestedRoutine leaves an in-place
<header>; forward; behind, and that forward is parsed while CurProc is still
the ENCLOSING routine, so ProcLexParent[ProcCount] := CurProc at RegisterProc
— the documented single chokepoint for every Proc* table — is the whole
mechanism. The ticket sent a reader to the harder place because its author
reasoned about the lift instead of looking at what the lift leaves behind.
Residual, NOT fixed here
specialize TBox<TRec> in a nested routine still binds the OUTER TRec even
though a bare TRec in the same body now resolves correctly. The specialization
dedup compares concrete arguments by NAME string (SpecConcreteNames), and
two different types in two scopes share the spelling — a name standing in for
the type it names, one layer above the tables this ticket is about. Its own
ticket, or the group's next rung.
FIXED at 0221a024a — resolved 2026-09-07, all five tables re-measured
Code and fixture landed 2026-09-06; this resolution adds no diff and that is
stated on purpose, since a ticket closing with no code change looks the same as
a quiet re-file. Re-measured 2026-09-07 at compiler a8f1784cfad0, one probe per
table, against fpc 3.2.2 and against pin v407:
| table | probe | fpc | pxx HEAD | pin v407 |
|---|---|---|---|---|
| UCls | sibling routines' local TRec |
A 3 / B 1 |
A 3 / B 1 |
refuses |
| alias | nested routine's TRec shadows enclosing |
inner 1 / outer 3 |
same | inner 3 |
| set const | sibling routines' S |
A TRUE / B FALSE TRUE |
same | B TRUE FALSE |
| string const | nested reads ENCLOSING const | from outer |
same | undefined variable |
| Syms | control, already scoped | — | — | — |
Five for five at HEAD, none at the pin. Inert until the next pin for anything
built against $(PXX_STABLE).
The residual is confirmed still open, and it needed the SAME NAME to see
specialize TBox<TRec> in a nested routine. Measured today with DISTINCT type
names first (TRec outer, TRec2 inner) and it passed — which would have
read as the residual being closed too. With the same spelling in both scopes it
fails:
pxx pascal26:11: error: "s": no such member on this record/class
fpc inner 1 / outer 3
SpecConcreteNames keys a specialization by the argument's NAME STRING, so two
different types sharing a spelling collapse into one specialization — and a probe
that renames the inner type to keep it readable removes the only thing under
test. That is [[bug-p-a-specializations-concrete-argument-is-keyed-by-its-spelling-so-two-scopes-types-collide]],
open, and the row belongs to it rather than here.
Log
- 2026-09-08 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit c77f2c91c.