← board

duplicate class name is scope-blind: nested classes collide across enclosing scopes

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.

  1. Scope, recorded. ParsingClassBodyCi is set while a class body is parsed (exactly the shape ParsingClassConstCi already 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 like FindClassConst.

  2. 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.Inner name. 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, and test_object_ref_array_identity.pas still compiles).

  3. The qualifier now MEANS something. This is the part that made the fix more than a suppression: with the error removed, var b: tother.tinner silently bound to touter's — both are classes, and the wrong one simply lacks the fields, so b.w was rejected and b.x accepted. 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 named tinner).

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

Log