duplicate class name is scope-blind: nested classes collide across enclosing scopes
- Type: bug (duplicate-declaration detection) — Track A
- Status: done
- Opened: 2026-08-14
- Filed by: Track T, from
task-t-enroll-pascal-conformance-tier. T owns the tool, never the bug — this is the owning lane's. - Introduced:
1c24510b3"fix(pascal): a duplicate class name in one unit is an error — and pylib had one" (2026-07-30), 4480 commits ago.
Why A and not P. The check is in the shared compiler/parser.inc (~26601),
and 1c24510b3 had to touch compiler/pyparser.inc in the same breath to stop
NilPy classes reading as redeclarations of their own stubs — so this is shared
duplicate-declaration machinery serving two frontends, not Pascal-dialect
syntax. Same call, and the same day, as
[[compat-pascal-strict-fpc-should-reject-a-duplicate-identifier-in-one-scope]], which
routed duplicate-identifier detection to A for exactly this reason. That
ticket is this one's mirror image and the pair is worth reading together:
it is pxx being too lax where FPC rejects (and is therefore compat,
behind --strict-fpc); this is pxx being too strict where FPC accepts,
which makes it a plain bug — legal Pascal that will not compile.
What
The check rejects a class name that is already declared anywhere in the unit, with no regard for the scope that declares it. Object Pascal nests types inside classes, so the same identifier legally names different types in different enclosing scopes — and a generic's nested types are re-materialized once per specialization, which the flat namespace also reads as a redeclaration.
Both arms, reduced from the failing conformance tests:
Arm 1 — same nested name under two different outer classes (10 lines):
program rep1;
{$mode delphi}
type
touter = class
type tinner = class end;
end;
tother = class
type tinner = class end; { <-- rejected }
end;
begin
end.
pascal26:8: error: duplicate class name tinner — one of that name is already
declared in this unit, and every use of the name binds to the FIRST declaration
touter.tinner and tother.tinner are distinct types with distinct qualified
names. Nothing here is ambiguous to a reader or to FPC.
Arm 2 — a nested specialization alias, once per outer specialization:
program rep4;
{$mode objfpc}
type
generic TUsed<T> = class
var f: T;
end;
generic TBox<T> = class
type
TItem = record Field: T; end;
TMyUsed = specialize TUsed<TItem>; { <-- rejected on the 2nd specialization }
var
f: TMyUsed;
end;
var
a: specialize TBox<LongInt>;
b: specialize TBox<Pointer>;
begin
a := nil; b := nil;
end.
TBox<LongInt>.TMyUsed and TBox<Pointer>.TMyUsed are supposed to be two
different classes — that is precisely what tgeneric72.pp's header comment says
it is testing ("that the two specializations of TUsedGeneric ... are unique").
The check counts the second materialization as a duplicate of the first.
What is NOT broken
Worth recording, because it narrows the fix and rules out the obvious guess. The commit that added the check explicitly handled forward stubs, and that handling works — a nested forward filled by its full declaration compiles:
tc = class
type
tforward = class;
tforward = class end; { ok — UClsForward is honoured }
end.
So this is not the forward-stub path. It is the namespace the check consults.
Root cause, as far as Track T took it
FindUClass-style lookup over a flat per-unit class table (the same flatness
[[bug-p-scope-hiding-covers-routines-but-not-types-and-classes]] describes one
level up, for uses-order resolution). The duplicate test asks "is this name
taken in the unit?" when the question is "is it taken in this declaring
scope?" — for a nested type the scope is the enclosing class, and for a
generic's nested type it is the enclosing specialization.
Per devdocs/dev/normalise-dont-special-case.md, the sibling to grep before
closing: the same flat table is what makes the two arms above one bug rather
than two, and a fix that special-cases nesting without also keying on the
specialization instance will fix tclass13b and leave tgeneric72 red.
How it stayed invisible for 4480 commits
The FPC conformance battery is enrolled in no tier — it runs only when a human
types it, which is the gap task-t-enroll-pascal-conformance-tier exists to
close. The battery was recorded at 0 fail at its burn-down; it is at 2 fail
today, and nothing observed the transition. This ticket is the first thing the
enrolment found.
Reproduce
tools/run_pascal_conformance.sh ./compiler/pascal26 \
library_candidates/fpc-testsuite/tests/test --only 'tclass13b.pp' --all
tools/run_pascal_conformance.sh ./compiler/pascal26 \
library_candidates/fpc-testsuite/tests/test --only 'tgeneric72.pp' --all
Note the compiler path must be one whose directory has builtin/ beside it and
lib/ above it — the runner compiles from inside the suite directory, so a bare
./compiler/pascal26 relative path does not survive its cd.
Gate
make test + self-host fixedpoint (byte-identical), plus both conformance tests
above passing without a pxx.skip entry. The check's original purpose must
survive: test_object_ref_array_identity.pas and the pylib duplicate the commit
found must still be caught.
If this will not be fixed soon
Track T's enrolment leaves shards 2 and 3 of test-pascal-conformance red, and
a red shard cannot report a further regression — the other ~90 programs in
each shard lose their NEW-RED signal until this clears. If the fix is not near,
skip-listing these two in test/pascal-conformance/pxx.skip with a reference to
this ticket restores that signal. That file is the owning lane's to edit, not
T's, which is why this is a note rather than a commit.
Related: [[task-t-enroll-pascal-conformance-tier]], [[bug-p-scope-hiding-covers-routines-but-not-types-and-classes]].
Fixed 2026-08-16 (Track A+C+P+N session)
Track T's read was right, including the warning about the sibling arm: a fix
that special-cased nesting without keying on the specialization instance would
have left tgeneric72 red. It did not need keying, because the mechanism below
is per-DECLARING-CLASS and a specialization is its own class.
Three pieces.
-
Scope, recorded.
ParsingClassBodyCiis set while a class body is parsed (exactly the shapeParsingClassConstCialready had for class consts,bug-pascal-class-const-visibility), and every type declared there registers in a nested-type registry: (owning class ci, bare name) -> ci, walked own-then-ancestors likeFindClassConst. -
The duplicate check asks the right question. Inside a class body a repeated name is not a unit-level redeclaration, so instead of erroring the later type is registered under its QUALIFIED
Outer.Innername. The FIRST nested type of a bare name keeps the bare one, so every existing unqualified use resolves exactly as before, and the check's original purpose is untouched at unit scope (TA = class ... end; TA = class ... end;is still an error, andtest_object_ref_array_identity.passtill compiles). -
The qualifier now MEANS something. This is the part that made the fix more than a suppression: with the error removed,
var b: tother.tinnersilently bound to touter's — both are classes, and the wrong one simply lacks the fields, sob.wwas rejected andb.xaccepted. The two sites that used to strip the qualifier ("it only disambiguates the parse") now consult the registry first: type position (ParseTypeKind) and the constructor fast path (TOuter.TInner.Create, which previously asked the owner for a method namedtinner).
Known remaining gap, filed separately rather than half-built: a nested
class's METHOD IMPLEMENTATION cannot be spelled function touter.tinner.Tag;
— the implementation-header parser takes one qualifier. Nested classes with
methods declared and implemented inline are unaffected;
[[bug-p-a-nested-class-method-implementation-takes-only-one-qualifier]].
Verified
tclass13b.ppandtgeneric72.ppboth compile (both are%norun, so that IS the test) — nopxx.skipentry needed, so Track T's two conformance shards go green and keep their NEW-RED signal for the other ~180 programs.test/test_nested_class_type_scoping.pas(new, in the Makefile): two classes each declaringtinnerwith different fields, the forward-stub form the original commit added, and the distinctness of the two instances —total ok 5 / 5under both FPC 3.2.2 and pxx.tools/gate.sh quickGREEN, self-host byte-identical, FPC seed canary green.
Log
- 2026-08-16 — resolved, commit 0ad0bdd04.