The repro — refused by pin and HEAD alike, compiled by fpc
program fwd;
{$mode objfpc}
type
TFactory = class
private type
PPRec = ^PRec; { <- reported here }
PRec = ^TRec; { <- declared here }
TRec = packed record a: LongInt; end;
public
class function Go: LongInt; static;
end;
class function TFactory.Go: LongInt;
var r: TRec; p: PRec; pp: PPRec;
begin r.a := 7; p := @r; pp := @p; Result := pp^^.a; end;
begin WriteLn('fwd ', TFactory.Go); end.
fpc -Mobjfpc prints fwd 7. pxx: forward type not resolved: PRec.
Measured across four spellings, all refused, so none of them is the boundary:
objfpc and delphi mode; as a program and as a unit interface. Pin v407 and HEAD
4d1b041a7fc9 give the identical message, so this is not today's regression.
Why it is filed apart from the regression it explains
The rtl-generics corpus contains this construct at generics.defaults.pas:224
and used to compile past it — the wall was at 2729 until ad7c03b03. So the
corpus case was being carried by something that commit removed. Fixing the
latent bug here would make the corpus immune to that, but the two questions are
different and only this one has a repro that stands on its own.
Do not start debugging at the reported line. The check is deferred: truncating the corpus unit at line 236 and compiling it parses the same block cleanly on both compilers. The line in the message is where the unresolved name was USED, not where the failure is decided.
Resolution (2026-09-09, frankZ) — the kind, not the depth
The ticket's own repro pointed one step too narrow. It shows
PPRec = ^PRec; PRec = ^TRec; TRec = record, which reads as a defect about
pointer-to-pointer chains. Chain depth is irrelevant. Measured, eighteen probes:
| forward target is a nested… | before |
|---|---|
| CLASS / RECORD | resolved |
| ARRAY | resolved |
| ENUM | resolved |
| plain scalar ALIAS | refused |
| POINTER alias | refused |
| SUBRANGE | refused |
and at UNIT level every one of them resolved. So the axis is the target's KIND crossed with the declaration being NESTED — five passing and three failing, which is a per-TABLE shape and not a per-construct one.
Declaration order is what made it look like a chain problem: with PRec written
before PPRec the same three types compiled throughout. A probe that happened
to use that order measured nothing. Both orders are asserted now.
Cause — every arm was working
DrainPendingPtrTargets runs at the compilation unit's closing end., and
PendingPtrTargetResolved asked:
FindNestedType(PendPtrClassCi[i], nm)— butAddNestedTypeis only ever called fromAddClassLikeType, so the registry holds class-like nested types and nothing else.- then the flat table:
FindTypeAlias(nm)— which filters candidates throughAliasVisibleHere→ClassOwnerVisibleHere, and that readsParsingClassBodyCi. At the drain pointParsingClassBodyCiis -1.
So FindTypeAlias answered, correctly, that a nested alias is not visible from
here. No arm errored; the scoped one was too narrow and the unscoped one was
asked from the wrong scope. IsRecordType, FindArrayType and FindEnumType
carry no owner column at all, which is the only reason those three kinds passed
— they cannot be asked a scoped question, so they answered a global one and
happened to be right.
That is "every instrument that lies, lies by being CORRECT ABOUT SOMETHING ELSE", and it is why the reported line is not where the failure is decided — the ticket already warned about that half.
Fix — one predicate that already existed
ClassDeclaresTypeNamed(ci, nm) is exactly the question: does this class, or an
ancestor, declare a type of this name. Its own header records that
FindNestedType answers -1 for nested procedural, subrange, enum, array and
plain aliases, and that the Alias* table keyed by AliasOwnerCi is where those
rows live. It calls FindNestedType first, so nothing that resolved before can
stop resolving, and it walks the ancestor chain, which the two arms did not.
Two lines changed.
Controls
test/test_a_nested_forward_pointer_does_not_see_another_classes_type_fail.pas
is the row that makes the fix correct rather than merely working: PB declared
in TOther, referenced from TFactory. The easy wrong fix — falling back to
an unscoped alias scan — compiles that file. fpc refuses it too, so it is
parity and not our own rule. A name declared nowhere is also still refused, but
that row cannot tell a scoped fix from an unscoped one, which is why it is not a
separate file.
It clears the regression it was filed behind
[[bug-p-the-generics-corpus-wall-moved-backward-from-2729-to-224]] (frankH):
generics.defaults.pas now COMPILES and the driver RUNS (drv ok) — not merely
past :224 but past the OLD :2729 wall as well.
Attributed rather than assumed: I reverted the one edit with
git checkout HEAD -- compiler/pasparser_decl.inc, rebuilt, and got
:224 forward type not resolved: PEqualityComparerVMT — frankS's exact symptom
— then reapplied the patch and rebuilt to a byte-identical 4f4875267033.
There is no residual question — frankH settled it, and my note above was wrong
I closed this saying "whether something worth keeping was lost in ad7c03b03 is
still frankH's question". It is not a question. frankH had already measured it,
on the full unit and before this fix:
- Identical input, both compilers fail at 224. The smallest reproducing
prefix (interface truncated at 884) prints
:224 forward type not resolved: PEqualityComparerVMTunder HEAD and under pin v407. The pin fails it too, soad7c03b03never caused the 224 failure — it is this latent bug, which was already there. - The drain runs after the whole unit parses (
pasparser_prog.inc:2068) — the same fact this fix is built on, read from the other side. So reaching that diagnostic means the unit parsed completely, and the line it names is where the^Twas DECLARED.
Together: at 178270aba the parse stopped at 2729 and the drain never ran; at
ad7c03b03 the parse finishes and the drain finally speaks, about a declaration
2500 lines earlier. The wall moved FORWARD, to the end of the unit. 224 < 2729 reads as backward only because one number is a stopping point and the other
is a declaration site. Nothing was removed and nothing is owed a probe.
frankH notes one honest gap so nobody inherits it as an implied fact: they could
not reduce the pin's own wall (System.Integer(ALeft) - System.Integer(ARight)
inside TCompare.UInt8 — standalone it compiles and prints 5 on both), so the
exact construct ad7c03b03 unblocked was never identified. The two measurements
above stand without it.
Log
- 2026-09-09 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 2242a5903.