TypeRef: migrate consumers lane by lane
Follow-up to [[feature-a-typeref-handle]], which landed TTypeRef
(compiler/defs.inc) additively — the record exists, nothing reads it yet.
This ticket is the actual payoff: replacing the ~90+ parallel-array sites
(SymPtrBaseTk/SymPtrBaseRec, UFldPtrElemTk/UFldPtrElemRec,
ProcRetPtrElemTk, LiftCapPtrTk/LiftCapProcSig, ...) with one TTypeRef
field per entity, one lane at a time.
See devdocs/dev/type-identity-as-substrate.md for the full design and why
this matters (the "one of six parallel arrays not written" bug class — four
such bugs landed in one session per that note's evidence table).
The candidate lane order below is OVERTAKEN by this ticket's own later sections — do not plan off it. Measured 2026-08-30: lane 1 (
TSymbol) is substantially landed, and lane 4 (proc returns) and record/class fields are DONE. Lanes 2 and 3 are not the live work. The live item is step 3 (theTTypeReffold), whose Track U blocker [[decide-typeref-gains-a-pointer-depth-field]] was answered on 2026-08-25 (28c19f214) and sat unnoticed for five days —PtrDepthhas since landed, readers-free. What remains is one coordinated commit that needsir.inc. See the 2026-08-30 section at the bottom.
Landing rule (unchanged from the parent ticket)
Each lane migrates ONE entity kind's parallel arrays to a single TTypeRef
field, lands under the self-host byte-identical gate, and does not touch any
other lane's arrays in the same change. Candidate lane order (smallest/
lowest-risk first, largest blast radius last):
TSymbol(Syms[]) — the largest single consumer (~26 parallel fields per the design note), but also the most self-contained: read/write sites are concentrated insymtab.inc/ir.inc.AliasPtrBaseTk/AliasPtrBaseRec(type aliases).UFldPtrElemTk/UFldPtrElemRec(class/record fields).ProcRetPtrElemTk/ProcRetProcSig(routine return types).LiftCapPtrTk/LiftCapProcSig(closure capture types).CTypeFnRetPBaseTk/CTypeFnRetPProcSig(C frontend type declarations).
Each lane: add a TTypeRef field alongside the existing parallel arrays,
populate it everywhere the old arrays are populated (do not remove the old
arrays yet — they stay the source of truth until every READ site is migrated
too), then migrate reads, then delete the old arrays once nothing references
them. Land incrementally — this is explicitly NOT a one-sitting change; the
parent ticket's Blocks [[feature-a-abi-oracle]] note means the oracle work
cannot start until at least enough lanes are migrated that a type's identity
can be read from one place instead of per-backend Syms[] probing.
Gate
make test + self-host byte-identical per lane; make test-nilpy too for any
lane NilPy code touches (2 and 3 especially, given the design note's NilPy
divergence evidence).
2026-08-01 — lane 1 (TSymbol) in progress; REVERTED two backend read-site
migrations, real sync gap found
SymTR (additive, 54b5684bc) landed, and several read sites migrated
cleanly: ir.inc's managed-record-copy and char-pointer-check (93ebb9377,
e9d1522c6) — both safe because the fields they read (RecName/PtrElemTk
for those specific symbols) are only ever written at the symbol's creation
chokepoint, so SymSyncTypeRef (called there) keeps SymTR correct.
Then a real gap was caught before more damage landed: SymSyncTypeRef is
only called at the 5 symtab.inc creation chokepoints
(AllocVar/AllocParam/AllocArray/AllocDynArray/AddConst), but
parser.inc/cparser.inc/pyparser.inc/ir.inc also mutate the SAME old
fields after creation — e.g. compiler/parser.inc:26006 (and :20565,
:21058) does idx := AllocArray(...) then immediately
Syms[idx].ElemRecName := LastTypeRecId for any array of <record>
parameter or local — a hot, common pattern, not an edge case. None of
those ~132 post-creation write sites call SymSyncTypeRef, so SymTR goes
stale for any symbol they touch.
Two already-merged backend migrations were exposed to exactly this:
573e1c4e7 (x86-64 ir_codegen.inc, migrated ElemRecName/ElemType/
RecName reads for anon-dynarray registration + managed-array-element
copy) and e3497fc73 (i386/arm32/aarch64/riscv32/xtensa RecName reads for
managed-record dynarray-append growth) — both read SymTR[...].ElemRec/
.RecId for exactly the array-of-record symbols the parser.inc sites above
mutate post-creation. Reverted (9b73ff4d6, e484fde67) rather than
leave a known, plausibly-widely-triggered stale-read gap live on master —
this is the same bug shape as the pconst-shift regression from the night
before (a parallel-array sync miss that a happy-path test corpus doesn't
happen to exercise), and that one only surfaced in a full test-core run,
not fixedpoint or testmgr --tier quick, so "tests passed" was not
sufficient evidence to keep it.
Next: before migrating any more reads (especially anything touching
RecName/ElemRecName/TypeKind/PtrElemTk/PtrElemRec/SymProcSig),
add SymSyncTypeRef calls after every post-creation write site — full
inventory (grep for Syms[<idx>].<field> := outside symtab.inc's
Alloc*/AddConst) is roughly: RecName/ElemRecName ~58 sites (ir.inc 3,
pyparser.inc ~42, parser.inc ~13), TypeKind ~9 (parser.inc),
PtrElemTk/PtrElemRec ~17+17 (cparser.inc, parser.inc),
SymProcSig ~11, ElemType/IsArray/ArrLen ~16. That write-side sync
is real, distinct work — do it before trusting any more SymTR reads, and
re-land the two reverted backend migrations only after it's done and
re-verified.
2026-08-03 — moved working/ -> unfinished/ (board maintenance)
working/ is a live lock: a ticket sits there only while an agent is actively
on it. This one had not been touched in three days, so the lock was stale and
next was reserving a Track A file-lane nobody held.
Not a revert, and nothing is half-applied: lane 1 (the SymTR parallel
array plus the full write-side sync) is landed and green — the self-host
fixedpoint has been rebuilt many times since against it. What remains is the
~68 read sites and the old-field deletion, which is why this is unfinished/
rather than done/. Re-claim it to continue.
Triage 2026-08-19 (Track D re-triage pass, pin v364)
Genuine feature, still wanted — no lane migrated, and no partial state.
Measured by counting consumers rather than reading the ticket: TTypeRef
appears 4 times in compiler/defs.inc (the additive declaration its parent
landed) and zero times in compiler/symtab.inc and compiler/ir.inc. So
lane 1 has not started, and nothing is half-applied.
Queue hygiene: this is in unfinished/, and it should not be. That folder
means work halted with the ticket incomplete, and a Track A ticket there is
flagged CRITICAL precisely because a half-applied compiler change can break the
self-host gate. Nothing is half-applied here — the parent landed cleanly and
this is untouched follow-up work. It belongs in backlog/. Not moved by this
read-only triage pass; flagged for whoever holds the A slot.
2026-08-24 — the 2026-08-19 triage is out of date, and lane 4 has a measured prerequisite
Correction first. The triage above says "TTypeRef appears 4 times in
compiler/defs.inc … and zero times in compiler/symtab.inc and
compiler/ir.inc. So lane 1 has not started." Measured today:
compiler/symtab.inc : 59 mentions compiler/ir.inc : 19
SymSyncTypeRef call sites : 105 (pyparser 54, pasparser_stmt 17, cparser 8,
ir 8, symtab 7, pasparser_decl 5, ...)
SymTR read sites already migrated : ir.inc, ir_codegen386.inc, ir_codegen_xtensa.inc
Lane 1 is substantially landed — write-side sync and a first set of reads. The triage counted a stale checkout or grepped the type name rather than the array name; either way, do not plan off it.
Lane 4 (proc return types) is now WANTED by a filed bug
[[bug-p-dereferencing-a-function-result-of-pointer-to-pchar-loses-the-shape]]:
GetQ^ where GetQ: ^PChar is wrong in the four contexts that refuse to guess,
because a proc records ProcRetPtrElemTk/Rec — the immediate pointee — and
nothing about the return pointer's DEPTH or ultimate BASE, so PChar and
^PChar are indistinguishable as return types. The lane is small: 10 write
sites, 7 read sites.
Two things block it, both measured today, both in this ticket's own subject
1. TTypeRef cannot express a pointer's depth. As declared
(defs.inc:1559) it has PtrBaseTk/PtrBaseRec and DynDepth — dynamic
array nesting — and no pointer-level count. Symbols carry theirs OUTSIDE
SymTR, in SymPtrDepth. So a TTypeRef today is strictly less expressive
than the parallel arrays it is meant to replace, for exactly the case that
motivates the migration.
2. And the field is fed the wrong half. SymSyncTypeRef does
SymTR[idx].PtrBaseTk := Ord(Syms[idx].PtrElemTk); { the IMMEDIATE pointee }
into a field whose name and comment say base. Measured with
PXXDBG=a.symptr:
| declaration | depth | PtrElemTk (immediate) |
SymPtrBaseTk (ultimate) |
|---|---|---|---|
pc: PChar |
1 | tyChar | tyChar |
ppc: ^PChar |
2 | tyPointer | tyChar |
pr: ^TRec |
1 | tyRecord | tyRecord (rec 29) |
raw: Pointer |
0 | tyUnknown | tyUnknown |
They coincide at depth ≤ 1, which is why the single existing reader
(ir.inc:2506, the char-pointer check) is correct today and why nothing has
caught it.
The obvious fix is NOT safe yet — this is the part worth recording
The clean shape is: TTypeRef gains PtrDepth, and PtrBaseTk/PtrBaseRec
are fed from SymPtrBaseTk/SymPtrBaseRec so they mean what they are named
(the immediate pointee stays derivable: depth > 1 ⇒ tyPointer, else the base —
which is exactly how the deref chain in pasparser_lval.inc already reasons).
The one existing reader then guards on PtrDepth = 1, which is a correctness
improvement in its own right.
It cannot land until the old arrays are themselves in lockstep, and they are not:
Syms[..].PtrElemTk := ... outside symtab.inc : 21 sites
ast_syminfer 6 · cparser 9 · pasparser_decl 2 · pasparser_proc 1
· pasparser_stmt 1 · pyparser 2
SymPtrDepth[..] := ... outside symtab.inc : 9 sites (all cparser)
So twelve post-creation sites set the immediate pointee and never touch depth
or base. Feeding PtrBaseTk from SymPtrBaseTk today would make it read
tyUnknown at every symbol those twelve touch, and the char-pointer check would
silently stop firing. That is the same shape as the two backend migrations
reverted on 2026-08-01 (9b73ff4d6, e484fde67) — a parallel-array sync miss
that a happy-path corpus does not exercise — and the same lesson: sync the
write side first, then migrate reads.
Concrete next step for whoever takes this
- Make the pointer triple (
PtrElemTk/PtrElemRec,SymPtrDepth,SymPtrBaseTk/SymPtrBaseRec) written together at all 21 post-creation sites — aSetSymPointerType(idx, elemTk, elemRec, depth, baseTk, baseRec)helper, so there is one place to forget instead of five fields. That is independently valuable and is the actual root cause behind the filed bug. - Then add
PtrDepthtoTTypeRefand re-pointPtrBaseTk/Recat the ultimate base, updatingir.inc:2506to guard on depth. - Then lane 4 proper:
ProcRetTR, populated at the 10 sites, read by a newAN_CALLarm in the deref chain — which is what closes the filed bug.
Each step under make compiler/pascal26 + tools/gate.sh quick, and re-run the
88-pair PChar differential recorded in
[[refactor-centralize-managed-string-pchar-conversion]] (currently 5 diverging,
all of them the GetQ^ shape) as the acceptance check.
Also: this ticket is in backlog/, which the 2026-08-19 triage asked for.
That move happened. No action needed.
2026-08-24, step 1's first instalment — SetSymPointerType, and the inference site
The step-1 plan recorded above ("make the pointer triple written together at
all 21 post-creation sites — a SetSymPointerType helper, so there is one place
to forget instead of five fields") now has its helper and its first converted
call site.
The helpers
SetSymPointerType(idx, elemTk, elemRec, depth, baseTk, baseRec)— THE one place a symbol's pointer identity is written after itsAlloc*. All five fields, thenSymSyncTypeRef, soSymTRcannot go stale behind it.SetSymPointerTo(idx, elemTk, elemRec)— the single-level case, for a caller whose source records only the pointee.^Twhere T is not itself a pointer is depth 1 over base T; the two are the same fact, so deriving them is not a guess. When T is a pointer the depth is genuinely unknown and this declines, leaving depth 0 rather than inventing a 1 that would make^PCharclaim to be aPChar.
The depth = 0 escape is deliberate and is the honest half of the design: a
caller that does not know is at least visibly declining instead of silently
forgetting, which is the whole failure mode.
Converted: InferSymTypeFromNode (ast_syminfer.inc)
Six branches, and they split exactly along whether the source records depth:
| inferred from | source | result |
|---|---|---|
| another SYMBOL | SymPtrDepth/SymPtrBaseTk/Rec |
exact |
| a pointer ALIAS | AliasPtrDepth/AliasPtrBaseTk/Rec |
exact |
PChar(x) |
definition | exact (1 over tyChar) |
@x |
x's own type | derived, declines if x is a pointer |
| a record FIELD | UFldPtrElemTk/Rec only — no UFldPtrDepth |
derived |
| a function RESULT | ProcRetPtrElemTk/Rec only — no depth |
derived |
The last two are the shortfalls this ticket exists to remove; the function-result one is filed as [[bug-p-dereferencing-a-function-result-of-pointer-to-pchar-loses-the-shape]].
What it fixed, and how it was proven not to break anything else
var q := pp where pp: ^PChar lost the char-ness one level in, because only
the pointee was copied:
before: 132814934638624 'x' + q^ -> 132814934638744 q^ = 'alpha' -> FALSE
after: alpha xalpha TRUE
Proven neutral by an A/B binary comparison, which is the technique this ticket's 2026-08-01 revert lacked: the compiler built before the change and the compiler built after it were each used to compile the same sources, and the resulting binaries diffed.
compiler.pas BINARY IDENTICAL
test_pchar_pointer_to_pchar.pas BINARY IDENTICAL
test_pchar_array_of_pointer_to_pchar BINARY IDENTICAL
test_not_operand_type_matrix.pas BINARY IDENTICAL
test_basic_comprehensive.bas BINARY IDENTICAL
c_builtin_bits.c BINARY IDENTICAL
That is stronger than "the tests pass" — it is "no emitted byte moved, in any frontend" — and it is the standard the remaining conversions should be held to.
Test
test/test_inferred_pointer_keeps_its_depth.pas. FPC cannot compile it (inline
var in a statement block is a pxx/Delphi form, not objfpc), so the oracle is
the explicitly typed twin printed beside each inferred row: every line must
have two equal halves, and the explicit half is separately pinned against fpc
3.2.2 by test_pchar_pointer_to_pchar.pas. test-core asserts both the
recorded output and the halves-are-equal invariant on its own, because a pair
that drifted apart would still match a regenerated .expected. Verified to FAIL
on the pre-change compiler, on all three affected rows. Cross-checked on
i386 / aarch64 / arm32 / riscv32.
Step 1 is DONE — every post-creation site now goes through the helper
grep 'Syms\[.*\].PtrElemTk :=' outside symtab.inc returns nothing.
The 15 remaining sites landed in two A/B-verified batches after the
ast_syminfer one: pasparser_decl 2, pasparser_proc 1, pasparser_stmt 1,
pyparser 2, then cparser 9. Reference binary compiled the same sources
before and after each batch; compiler.pas, three PChar/not/depth Pascal
tests, test_basic_comprehensive.bas, three NilPy tests and eight C tests
(VLA, 2-D row length, decay stride, fn-pointer array, ** return, called
result, ptr-array field) were binary identical every time. The compiler's
own code section shrank 1,306 bytes in the cparser batch alone — the helper
replaces five stores at nine sites.
Two findings worth carrying forward:
- C's parameter table has the full triple, Pascal's does not.
cparserthreadspdepths[i]/pbasetk[i]/pbaserec[i]alongsidepelemtk, so a Cchar **argvparameter keeps its depth.pasparser_proc's parallelptypesPtrElemTk/ptypesPtrElemRechave no depth sibling, so that site had to take the derived single-level answer (SetSymPointerTo) and a Pascal^PCharPARAMETER still arrives at depth 1 instead of 2. That is the same class of loss as the inference bug just fixed, in the one place a helper call cannot paper over: the metadata genuinely is not recorded. Extending the param table is a prerequisite for step 2'sPtrDepth = 1guard being safe for Pascal parameters — do it as the first half of step 2, not as an afterthought. - Do not edit sources while a gate runs.
gate.sh quickrebuilds from the working tree; a mid-run edit produced a bogus RED ("the fixedpoint reached from PINNED differs from compiler/pascal26") that was pure contamination. The re-run on a quiet tree was GREEN with no change to the sources.
Lane 4 is DONE too, and it went the other way round
Proc returns got the triple (ProcRetPtrDepth / ProcRetPtrBaseTk / Rec) at
all nine declaration sites, and record/class FIELDS got theirs
(UFldPtrDepth / UFldPtrBaseTk / Rec) right after, because the 72-program
cross product found them one after the other. Both closed
bug-p-dereferencing-a-function-result-of-pointer-to-pchar-loses-the-shape;
that ticket asked for TTypeRef FIRST and got parallel arrays instead. The
reasoning is written out in the resolved ticket -- in short, the same five
fields are populated at the same sites either way, folding arrays into a record
is mechanical, and waiting cost silently wrong values in the field.
The reader is the expensive half, every single time. Populating proc-return
depth fixed nothing on its own: c := GetQ^; WriteLn(c) printed the string
while WriteLn(GetQ^) printed the address, same binary, same declaration.
ApplyCallResultPtrSuffix is a FOURTH copy of the pointer walk (with the main
deref chain, pasparser_expr's parenthesised tail, and the inherited-call tail)
and it stamped none of the node tags the rest of the compiler reads. Four
tickets running now have ended "the metadata was there / easy, the reader was
missing" -- when a pointer shape is wrong, look at the reader before the table.
Cross-product acceptance line: 72/72 agree with fpc 3.2.2 (10 source shapes x 8 contexts). Before this session's fixes: 67/72, and before the array fix, 36 divergences out of 88.
Still open, in order
The other 20 post-creation sites.DONELane 4 (proc returns).DONE, plus fields, which were not on the list.DONE (TTypeRefgainsPtrDepthandPtrBaseTk/Recare re-pointed at the ultimate base, withir.incguarding onPtrDepth = 1.972b8be8e, plusa296024e9for ir.inc's remaining readers). The Track U fork [[decide-typeref-gains-a-pointer-depth-field]] was answered in28c19f214;PtrDepthlanded readers-free in the 2026-08-30 frankA pass, and the fold is the 2026-08-30 frankwasm section at the bottom. The recorded Pascal-parameter prerequisite measured STALE and cost nothing.- The remaining reader duplication: four copies of the pointer walk, none of which can be deleted without the other three agreeing on the node tags. That is a bigger, better-value refactor than the table fold and it is not filed yet -- file it before starting, with the four sites named.
2026-08-30 (frankA) — PtrDepth landed, and the recorded blocker was stale
Took this under a coordinator constraint: no compiler/ir.inc (held for the
cir.inc carve-out). That turned out to select exactly the right slice.
The lane assessment, since the dispatch asked for one
The ticket's lane list is largely overtaken by its own later sections. Lane 1
(TSymbol) is substantially landed; lane 4 (proc returns) and record/class
FIELDS are DONE. The live item is step 3 — the TTypeRef fold — and
decide-typeref-gains-a-pointer-depth-field is answered (28c19f214), so
it is no longer blocked. The decision's own step 1 is "add PtrDepth to
TTypeRef first, readers-free", which touches defs.inc + symtab.inc and
not ir.inc. That is what landed here. Step 2 — re-pointing
PtrBaseTk/Rec at the ultimate base — does need ir.inc, in the same
change, and is deliberately not started.
The correction, and it is the reason this was worth doing now
symtab.inc's SetSymPointerType comment stated, in the present tense, that
"twelve symbols carried an immediate pointee over depth 0 and base tyUnknown …
and it is why feature-a-typeref-migrate-consumers cannot re-point
TTypeRef.PtrBaseTk at the real base yet." That was the stated reason the
migration could not proceed, and it is out of date — it was written
2026-08-24 when 12 of 21 sites were unconverted, and step 1 finished afterwards.
Re-measured 2026-08-30:
Syms[..].PtrElemTk := outside symtab.inc : 0
SymPtrDepth[..] := outside symtab.inc : 0
SetSymPointerType/To call sites outside it : 21
ast_syminfer 6 · cparser 9 · pasparser_decl 2 · pyparser 2
· pasparser_proc 1 · pasparser_stmt 1
The 21 is the denominator that makes the two zeros mean converted rather than
no such sites exist. The write side is in lockstep. The remaining blocker is
purely the reader: ir.inc's char-pointer check reads PtrBaseTk as the
immediate pointee and must begin guarding on PtrDepth in the same commit. The
comment is corrected in place, with the old text kept as history.
Landed
TTypeRef.PtrDepth (defs.inc), populated from SymPtrDepth[idx] in
SymSyncTypeRef as a literal copy like every other line there. Readers-free
— nothing consults it yet, which is what makes it safe mid-migration. The two
Base-named fields now carry a note saying they hold the immediate pointee
despite their names, so the next reader is not misled by the same gap that made
^PChar indistinguishable from PChar.
A/B binary comparison — the standard this ticket sets, eight sources, four
frontends, all BINARY IDENTICAL: compiler.pas,
test_pchar_pointer_to_pchar.pas, test_inferred_pointer_keeps_its_depth.pas,
test_not_operand_type_matrix.pas, test_basic_comprehensive.bas,
c_builtin_bits.c, test_nilpy_configparser.npy,
test_nilpy_float_methods.npy. Compiler built before and after, same sources,
diffed. Self-host fixedpoint 1 round 3b00f387f0df; gate.sh quick GREEN.
Item 4's count is wrong, measured — filed as its own ticket
The "still open" list says "four copies of the pointer walk". Listed rather
than counted: nine, of which six are the Pascal frontend
(pasparser_lval.inc:4770, pasparser_expr.inc:930/6391/6740,
pasparser_stmt.inc:6444/6559) and three are NilPy's
(pyparser.inc:42567/47378/47524), which are legitimately duplicated across
languages per the-substrate-is-ast-and-ir-not-the-parser.md. So the refactor
is 6 Pascal-side copies, not 4. Filed as
[[refactor-a-the-pointer-suffix-walk-has-six-copies-in-the-pascal-frontend]] as
this ticket asked ("file it before starting, with the four sites named").
Next, for whoever holds ir.inc
One coordinated commit: re-point PtrBaseTk/PtrBaseRec at
SymPtrBaseTk/SymPtrBaseRec, and change ir.inc's char-pointer check to
require PtrDepth = 1. PtrDepth is already there and already populated. Note
the outstanding caveat recorded 2026-08-24: Pascal's parameter table still has
no depth sibling, so a Pascal ^PChar parameter arrives at depth 1 rather than
2 — check whether that makes the PtrDepth = 1 guard wrong for parameters
before relying on it.
2026-08-30 (frankwasm) — step 2 landed: the fold, with the guard it needed
TTypeRef.PtrBaseTk/PtrBaseRec now hold the ultimate base (they were
holding the immediate pointee under a name that said base), and ir.inc's
char-pointer predicate gained the PtrDepth = 1 guard in the same commit. Two
edits, exactly as step 3 of the "Still open" list specified.
The recorded prerequisite was stale — measured, not assumed
The caveat carried since 2026-08-24 — "Pascal's parameter table still has no
depth sibling, so a ^PChar parameter arrives at depth 1 rather than 2; check
before relying on the PtrDepth = 1 guard" — is false at HEAD, and the
earlier note that extending the param table was "a prerequisite for step 2 …
do it as the first half of step 2" is therefore discharged with no work.
ptypesPtrDepth / ptypesPtrBaseTk / ptypesPtrBaseRec exist in
pasparser_proc.inc, are assigned per parameter group, and reach the symbol
through SetSymPointerType. Measured:
Mixed(aInt: Integer; bPc: PChar; cPtr: Pointer; dPpc: ^PChar; ePb: ^Byte)
bPc depth=1 basetk=3 (tyChar) dPpc depth=2 basetk=3 (tyChar)
cPtr depth=0 basetk=0 ePb depth=1 basetk=8 (tyUInt8)
Order-independent — PcThenPpc and PpcThenPc both record correctly.
The probe that would have answered this in August could not see parameters
at all. PXXDBG=a.symptr fires from the Alloc* chokepoints, i.e. BEFORE
SetSymPointerType, so for a parameter it prints the recycled sym slot's
previous occupant. Read at face value that output says "every parameter in a
proc gets the last pointer parameter's shape" — a dramatic bug that is not
there. This is the second hole in that probe, and the same one its own header
comment already warns about for arrays: "a probe with a hole in it is worse
than no probe, because the silence reads as an answer." Fixed forward instead
of worked around: PXXDBG=p.ptrparam, which fires at the right moment, now
prints depth and ultimate base too (53f9b82e0). It printed only the immediate
pointee before, which is precisely the field that cannot tell PChar from
^PChar.
The guard is load-bearing, and the first measurement that said so was wrong
Removing the guard and rebuilding appeared to change the compiler's own output.
It did not: the working tree still had the guard removed, so compiler.pas
{$I}-included a different ir.inc. That compared two SOURCES, not two
behaviours — the contamination this ticket already warns about under "Do not
edit sources while a gate runs", reached by a different route. Re-measured on
one fixed tree, the three compilers agree on compiler.pas byte for byte, and
an instrumented build shows the guard rejects nothing while compiling it.
The honest discriminator is a bare depth-2 identifier in a PChar context:
var p: PChar; q: ^PChar; p := @s[0]; q := @p; WriteLn('A=', q);
pre-fold compiler A=4352408 (the address)
folded + guard A=4352408 byte-identical binary
folded, no guard A=<raw bytes> q read as a PChar
So the guard is exactly what keeps the fold behaviour-neutral, which is this ticket's own standard — not an extra.
What is NOT pinned, deliberately
The guard's only reachable observable is that bare-identifier arm, and fpc
3.2.2 rejects every shape that reaches it (WriteLn(q), 'x' + q over a
typed pointer). There is no oracle, and by CLAUDE.md's compat table "we accept
a form FPC rejects" is not a defect — so the answer is a pxx dialect call, not
a conformance fact, and no test asserts it. The guard is held to A/B
behaviour-neutrality instead. test/test_ptr_depth2_bases.pas is new coverage
of the adjacent real gap (depth 2 over ^Byte and ^ShortInt, which nothing
tested — ^PChar had two tests and its two siblings had none) and its header
says in as many words that it does not pin the guard.
Verification
A/B, compiler built before and after the fold, same sources, binaries diffed —
all identical: compiler.pas (and it equals the fixedpoint sha),
test_pchar_pointer_to_pchar.pas, test_inferred_pointer_keeps_its_depth.pas,
test_not_operand_type_matrix.pas, test_basic_comprehensive.bas,
c_builtin_bits.c, test_nilpy_configparser.npy,
test_nilpy_float_methods.npy, test_cross_write_pchar.pas. Self-host
fixedpoint verified (80f0bf81b554); gate.sh quick green.
test_ptr_depth2_bases.pas agrees with fpc 3.2.2 line for line.
Still open after this — nothing on the list
Step 3 is DONE, and so is step 4: frankA had already split it out earlier the
same day as
[[refactor-a-the-pointer-suffix-walk-has-six-copies-in-the-pascal-frontend]],
correcting the count to six on the way, and that ticket is in done/. I nearly
filed a duplicate of it before checking — the "still open" list reads as open
because nobody struck item 4 through when it moved.
ir.inc's own two remaining raw readers (the depth-2 stride case and arm 4's
q[i]) were migrated in a296024e9 rather than left: both want the ultimate
base, so they were unmigratable until the fold above, which is why they were
still there and why they belong to this change and not to a later sweep. See
the scope section below for what is left elsewhere and why it is not a ticket.
Scope, since two sessions have now asked whether this ticket is still legible
It is, but only its "Still open" list is live. The survey sections above
(the site counts, and every mention of parser.inc) are a RECORD of a survey
run before the pasparser_*.inc split, and parser.inc has not existed since.
Per CLAUDE.md's precedence rule those are history and are deliberately left
unrewritten rather than "fixed" — but they should not be read as instructions,
and their counts are superseded by the measured ones below.
Re-measured at HEAD, raw SymPtrDepth/SymPtrBaseTk/SymPtrBaseRec readers
left outside symtab.inc/defs.inc:
pasparser_lval.inc 9 cparser.inc 6 pyparser.inc 4
pasparser_decl.inc 3 ast_syminfer.inc 2 ir.inc 0
24 sites, all mechanical now that SymTR carries the ultimate base. ir.inc
is at zero, which is the file that mattered — it held the only reader whose
migration had semantic content.
So the ticket does not need re-scoping so much as closing: items 1-3 of its
own list are done, and item 4 was already split out by frankA earlier the same
day as
[[refactor-a-the-pointer-suffix-walk-has-six-copies-in-the-pascal-frontend]]
(which also corrected the count from four to six, by listing rather than
counting) and is itself in done/. Nothing on the list is left, so this ticket
resolves rather than carries forward.
The 24 remaining raw-array readers above are NOT a residue of this ticket's
list — they are the mechanical tail of lane 1, safe to sweep whenever the file
that holds them is free, and worth nothing on their own. A follow-up ticket for
them would be make-work at prio 10 that the ranker scans forever, which is the
exact leak CLAUDE.md's compat table warns about; whoever next edits
pasparser_lval.inc or cparser.inc for a real reason should convert the
readers in front of them and no more.
Log
- 2026-08-30 — resolved, commit 6a35007de.