← board

A user type whose name shadows a builtin is unusable as a variable's type

The sharpest repro is self-inconsistency, not a disagreement with FPC

Found by frankwasm, re-measured by frank-rust on a fresh HEAD build a673fa2206c4 (HEAD 26b5a5d066ed, 2026-08-31):

program SelfInc;
type Currency = record a, b, c: Integer; end;
     TA = array[0..SizeOf(Currency) - 1] of Byte;
var x: TA;
begin
  Writeln(SizeOf(Currency), ' ', SizeOf(x), ' ', Length(x));
end.
pxx : 12 8 8        fpc 3.2.2 : 12 12 12

The same expression text answers 12 and 8 in the same program, ten lines apart — 12 through the SizeOf path fixed in 582e4de09, 8 through ParseTypeKind in the array bound, which still consults the builtin name first.

Prefer this over the pxx-vs-FPC form as the ticket's headline repro, for two reasons. It needs no oracle to be obviously wrong, so nobody has to agree about what our Currency ought to be first. And self-inconsistency is the one form that cannot be ranked as a compat item under the compat ceiling — it is not "FPC accepts a form we reject", it is us giving two answers to one question.

Repro

program shadowvar;
type Currency = record a, b, c: Integer; end;
var v: Currency;
begin
  v.a := 1; v.b := 2; v.c := 3;
  WriteLn(SizeOf(Currency), ' ', SizeOf(v), ' ', v.a, v.b, v.c);
end.
pxx:  pascal26:5: error: a value of this type has no members
                         (only records, classes, interfaces and variants do)
FPC:  12 12 123

The type declaration itself is accepted; it is the use as a variable's type that silently resolves to the builtin, so v is an 8-byte Double slot with no members. Reproduces on the pinned compiler too — pre-existing, not a regression.

Any builtin name the declaration path knows will do: Currency, Comp, TDateTime, ValReal, LongBool. SizeOf(Currency) answers 12 correctly as of the precedence fix below, so the compiler now disagrees with itself — it sizes the user's record and declares the builtin.

Why it is the twin of the SizeOf bug, and where to fix it

[[bug-p-sizeof-rejects-twelve-type-names-that-a-declaration-accepts]] was the same precedence error in SizeOf: BuiltinTypeNameTk was consulted before the record/alias/array/enum/variable tables, so a builtin stole the name from a user declaration. That is fixed — the builtin is now demoted when a user declaration claims the name, and restored as a fallback when the user lookup resolves to nothing.

ParseTypeKind has the same ordering and has not been fixed. The fix should follow the same shape rather than a second mechanism (devdocs/dev/normalise-dont-special-case.md), and it should keep the same fallback: several builtin names ALSO match an internal registration, and shortstring is deliberately excluded from the shared table because it sets LastTypeStrCap and is not side-effect-free.

Ranked at 45, below its twin, deliberately

This one fails loudly at compile time. Its twin produced wrong sizesSizeOf(Currency) 12 → 8, a Boolean named longbool 1 → 4 — feeding GetMem and Move with no diagnostic at all. A program that hits this bug does not build; a program that hit the other one shipped.

Real code that would hit it is also narrower: shadowing Currency or TDateTime with your own record is legal Pascal and FPC accepts it, but it is not common. Rank it by how much real code redeclares a builtin name, not by how surprising the error message is.

Guard when it is fixed

test/test_sizeof_user_name_shadows_builtin.pas already covers the SizeOf half and is byte-identical to FPC 3.2.2. Extend it (or add a sibling) with the declaration half — use the shadowing type, do not merely size it — and keep its control rows: builtin names that nothing in the file shadows, which is what catches a fix that stops consulting the builtin table altogether. Note that asserting SizeOf(LongBool) = 4 is NOT a control in a file that declares a variable called longbool — that name means the variable, and FPC agrees.

A SECOND symptom, and it is why this outranks a "you cannot declare it" bug

SizeOf has two implementations. The expression one (ParseFactor) has the precedence fix. The constant-expression one (pasparser_expr.inc:10477, feature-sizeof-const-intrinsic-in-const-eval) resolves its operand by calling ParseTypeKind — so it inherits this bug verbatim:

type Currency = record a, b, c: Integer; end;
     TA = array[0..SizeOf(Currency) - 1] of Byte;   { pxx: 8   FPC: 12 }

The array silently gets 8 elements instead of 12. No diagnostic. So this ticket is not only "the type is unusable" — through the const path it also produces the same class of silent wrong size its twin did, in array bounds and default parameter values.

Fixing ParseTypeKind fixes both symptoms and both call sites, which is the argument for doing it there rather than patching the const-eval arm.

The other half of the fix: WHEN you are entitled to ask

Ordering alone is not sufficient, and this is measured elsewhere rather than guessed. frankwasm hit the same family at 19bb32f31 with the ordering ALREADY correct — FindUClass ran first and it still refused a legal program, because the user's declaration had not been parsed yet when the check ran. Fixed at d9e3420e5 by deferring the check to type-section close.

So the rule for this family is two-part: a name must be settled against the program, not against the builtin table, and asked at a point where the program is complete enough to answer. Asking at the wrong moment gives a correct lookup over an incomplete world.

SizeOf's expression path is not exposed to the second half — by the time a statement is parsed the type sections are closed, and the one ordering that looks wrong (SizeOf(Currency) textually before Currency is declared) is answered 8 by FPC too, because the name genuinely is not declared yet there. Measured both directions rather than assumed; whoever fixes ParseTypeKind should re-ask that question for the declaration path, where forward references inside one type section are ordinary.

Fixed 2026-09-02

ParseTypeKind already had the guard and it was one table too narrow:

tnHasAlias := FindTypeAlias(lo) >= 0;

Its own comment said "a source/RTL alias of this name beats every builtin name", and every builtin arm in the chain is guarded with not tnHasAlias. So an ALIAS beat a builtin and a RECORD did not. The SizeOf site fixed in 582e4de09 consults six tables for the same question. Same predicate, two places, one narrow — the umbrella's second shape (duplicated tables that disagree), not a second arm, so the fix is the seam and not a special case.

Now FindTypeAlias or IsRecordType or FindUClass or IsClassType or FindArrayType or FindEnumType, and renamed tnUserDecl, because "HasAlias" was an 80%-accurate name for what it now tests and those are worse than 0%-accurate ones. It asks the TYPE tables only: SizeOf legitimately also checks FindVarSym, but a variable named Currency does not make Currency a type in a type position.

The guarded-branch shape is unchanged, so this can still only ever make a declaration WIN, never the reverse.

Verified

Extended test/test_sizeof_user_name_shadows_builtin.pas — the sibling test from 582e4de09 — rather than adding a second file, since it is the same question. Its own comment recorded row j as deliberately NOT asserted because SizeOf(v) on a user record variable answered 8 before and after that fix; that is this defect, so the row is now asserted and the comment updated.

New rows: SizeOf(cv) = 12, member access cv.a + cv.b + cv.c = 6 (a hard compile error before), the const-eval path array[0..SizeOf(Currency)-1] = 12 (the 12 8 8 self-inconsistency), and the shadowing ARRAY type = 10. Plus the control that goes missing: an UNSHADOWED builtin must still resolve as a variable's type — WideChar (2) and ByteBool (1), chosen because no fallback here produces 2 or 1.

No expected value under test is 4 or 8. 12, 10, 6, 2, 1 — none is a number a fallback returns, so no row can pass by the machinery doing nothing.

Blast radius measured rather than assumed. The change can only affect a program that declares a type whose name collides with a builtin. Across lib/rtl and lib/pcl (265 declared type names) there is exactly ONE such name: Text, declared as a record in lib/rtl/textfile.pas and as a class in lib/pcl/tkinter.pas. A Text/TextFile round-trip compiles to byte-identical code/data/bss before and after. That is why the quick tier is proportionate here and the gate was not widened.

Worth recording: the first version of that collision measurement was a comm whose builtin-name list had several names per line, so it compared whole lines and could never match. It reported zero collisions — the right-looking answer — and only an injected must-be-reported name exposed it. The real answer was one collision, not zero.

FPC's default mode is not a disagreement

fpc selfinc.pas with no mode switch prints 6 6 6 where pxx prints 12 12 12, which looks like a fresh divergence and is not: mode fpc has Integer = 2 bytes (measured: SizeOf(Integer) is 2 there, 4 under -Mobjfpc). Under -Mobjfpc/-Mdelphi FPC prints 12 12 12, matching. The ticket's recorded fpc 3.2.2 : 12 12 12 is right; it was measured in a comparable mode.

Log