← board

A type alias drops the enum identity; a set drops its char element kind

Both found while fixing [[bug-p-a-subrange-bound-must-be-a-literal-token]] (landed 2026-09-05), both verified PRE-EXISTING against pin v403, and neither caused by that change.

1. An alias to an enum loses the member names

type D = (mon, tue, wed);
     TDays = D;
var a: TDays; b: D;
...
b := wed;  WriteLn(b);   { pxx: wed   fpc: wed  }
a := tue;  WriteLn(a);   { pxx: 1     fpc: tue  }
           WriteLn(mon); { pxx: mon   fpc: mon  }

A direct enum variable and a bare member literal both print correctly. Only the variable declared through the ALIAS falls back to the ordinal.

Cause, read rather than guessed: an enum is not a TTypeKind in this dialect — an enum symbol is tyInteger with SymEnumId >= 0 (the convention CEEnumId's own declaration in defs.inc states). RegisterGeneralAlias records AliasTk = tyInteger and there is no AliasEnumId column, so the identity has nowhere to go. ParseTypeKind has just run and left it in LastTypeEnumId, which is exactly the window AliasStrCap, AliasStrElemTk, AliasFileElemTk and (since 2026-09-05) AliasIsSub all read from — this is the same gap those each were, one type over.

Why the obvious test misses it: case a of mon: ... works, because enum members are resolvable globally rather than through the variable's type. So every control-flow use of the alias behaves, and only the WriteLn formatter — which needs the id to find the name table — shows it.

A NAMED subrange of an enum (type TW = sat..sun) has the same hole for the same reason; the INLINE spelling (var v: sat..sun) prints the member name correctly today, because there LastTypeEnumId reaches the symbol directly. That asymmetry is a good positive control for a fix.

2. Low/High of a set of a char subrange answer numbers

var b: set of 'c'..'k';
WriteLn(Low(b), ' ', High(b));   { pxx: 99 107    fpc: c k }

The BOUNDS survive (99 and 107 are right) — it is the element KIND that is dropped, so the values print as integers. set of 1..10 and set of sat..sun are correct, the latter only because the probe reads it through Ord.

ParseSetElemSpec has its own copy of the subrange parse guarded on tkInteger, so a char subrange element takes the else ParseTypeKind() branch and the set's element kind is recorded from a path that did not set it. That copy is the last of the three "parse lo..hi" copies — the other two were folded into one shared body on 2026-09-05 — and folding it is the likely fix, but it was left alone because the verification needs its own pass: Low/High of a set TYPE NAME (as opposed to a set VARIABLE) is refused outright here (undefined variable (SI)), so half the natural assertions are unavailable until that is settled too.

Suggested order

(1) first: it is one column plus the read sites, and it has a clean positive control in the inline-vs-alias asymmetry. (2) wants the third subrange copy folded, and wants the set-type-name Low/High question answered first.

The fork is filed

[[decide-how-a-type-carries-an-identity-its-kind-cannot-hold]] (Track U, p55) names this and the sized-boolean bug as the two families ONE mechanism would close, and recommends the side-channel arm — which is this ticket's suggestion (1) generalised. Settle that first; the carry sites are shared.

2026-09-05 — (2) re-measured at a moving tip, and split into three

frankA's "both bugs still reproduce" row was stamped at 167847e61, before the {$H-} commit touched pasparser_decl.inc and pasparser_expr.inc; they said so and stood down rather than re-take it. Re-taken here at tip e9a885ba2, compiler ba573b6cf02a. Row 1 reproduces exactly as written. Row 2 is not one defect but three, and the ticket above had measured only the first spelling:

spelling Low/High bounds element kind
var b: set of 'c'..'k' (inline) 99 107 — right dropped, prints ordinals
type TCS = set of 'c'..'k' (alias) 0 255 — WRONG dropped
Low(TCS) on the TYPE NAME refused: undefined variable (TCS)

All three confirmed pre-existing against the pinned compiler.

The bounds half is FIXED. ParseSetElemSpec sets elemLo := 0; elemHi := -1 and captures bounds only inside its tkInteger arm, so a subrange spelled any other way took else elemTk := ParseTypeKind() and registered Hi < Lo — "not a subrange" — after which Low/High fell back to the element TYPE's range. ParseTypeKind's own subrange tail had already left the correct bounds in LastTypeSubLo/Hi, live and one line away, unread. Only the NAMED spelling was wrong because only it reaches that branch. The fix reads the channel that was already correct; it is four lines and adds no column.

This does not pre-empt [[decide-how-a-type-carries-an-identity-its-kind-cannot-hold]]. The bounds are needed under EITHER arm of that fork — a side channel and a new TTypeKind both still have to know that the element range is 99..107 — so capturing them here decides nothing. The element KIND is the half that needs the fork, and it is untouched.

Attribution, by ablation. Removing the fix and rebuilding reproduced ba573b6cf02a byte-identically and put back exactly two rows: char alias 99 107 -> 0 255, enum-subrange alias 1 2 -> 0 3. The ablated build still COMPILED the probe, so the pin's unknown type: sun refusal was fixed earlier by someone else and is NOT claimed here.

Test: test/test_set_elem_bounds.pas + .expected (fpc 3.2.2's own output, seven rows). The inline rows are the control — they were right before the fix, so a run checking only the alias rows cannot tell a fix from a coincidence. The alias chars ck row is the element kind surviving the ALIAS path, which is why the two halves are separable at all: the inline spelling still prints 99107 there and stays open.

Still open on (2): the element KIND on the inline spelling (fork), and Low/High of a set TYPE NAME being refused outright.

Resolved — the enum half

One column, and the reason it was missing is the finding

RegisterGeneralAlias carries five facts out of the LastType* channels: a frozen string's capacity, a subrange's bounds, a file's element width, a managed string's element width, a pointer's target. Each is its own guarded block, and four of the five say some version of "exactly AliasStrCap's problem one type over". The enum id is the sixth and nobody wrote it.

That is the omission class arriving inside the duplication class. Reading the five copies against each other cannot find the sixth, because they agree — the instrument for a missing caller is the callee's own contract, not a diff of its callers.

AliasEnumId is stored +1, and on purpose

Its five neighbours use 0 as "not one" because 0 is not a valid capacity or kind. 0 IS a valid enum index, so an unwritten row would read as enum 0 — and an unwritten row is precisely how this fact went missing. With the bias, a registration site that forgets the column yields NONE and the omission is inert rather than silently wrong. Six sites call Inc(AliasCount); only one of them needed to learn anything.

The guard is EnumKindMatches, not LastTypeEnumId >= 0

set of TCol leaves the ELEMENT's id in that global. Capturing unguarded would stamp a SET alias with its element's identity and print a bitset as a member name. EnumKindMatches is the predicate seven other sites already use for exactly this question, and its own header records that the kind test "is the only thing stopping a set from inheriting a member name". This is its eighth caller, and CONTROL set alias in the test is the row that fails without it.

Its forward declaration MOVED from pasparser_name.inc to symtab.inc's existing forward block rather than being duplicated: a duplicate forward across two .inc files builds clean, passes --tier quick, and is caught only by the FPC seed canary — a class CLAUDE.md names by that description. The canary passed.

Low/High of the alias NAME was a second site, and a double case

Low(TDays) answered -2147483648. The alias arm fell through to IntToTypeKind(AliasTk[]), which for an enum is tyInteger, and OrdinalTypeBound answered about Integer and returned True before the enum arm below was ever reached — so for d := Low(TD) to High(TD) ran four billion times where the identical loop over D runs four.

Fixed in BOTH twins (TryConstHighLowValue and TryFoldHighLowType), whose own comment already says "these two are one concept in two places, so they change together". Neither got a second enum arm: the alias resolves the id and lets the existing arm answer, so the identity that makes WriteLn print mon cannot drift between the two spellings.

The ticket's own suggested control is in the test

A NAMED subrange OF an enum (type TWork = tue..thu) takes the AliasIsSub arm, and the INLINE spelling of the same subrange printed member names all along. That asymmetry is what puts the defect at the alias boundary rather than in the formatter, and it is a row.

Found while fixing, filed rather than folded

Low(a) / High(a) on an enum VARIABLE print 0 2 where fpc prints mon wed — and the DIRECT enum variable is equally wrong, so it is not an alias defect and does not belong here. [[bug-p-low-and-high-of-an-enum-variable-print-the-ordinal]].