The shape
test/uclshelperdispatch.pas declares TTest, a class helper for TTest, and
generic function DoTest<T: TTest>: LongInt whose body is Result := T.CS.
test/test_a_class_helper_on_a_class_level_method.pas declares TTest2 = class(TTest) with its own CS (3) and a class helper for TTest2 (4).
row pxx fpc 3.2.2
specialize DoTest<TTest2> 4 3 <- this ticket
TTest2.CS 4 4 <- agrees, and is the control
Same class, same helper, same program, and the two rows differ in fpc. That is the finding: the only thing separating them is that one call goes through a template body imported from another unit. fpc binds the body's names where the template was DECLARED; pxx binds them where it is SPECIALIZED.
Why this could not be seen before 2026-09-09
pxx answered 3 and that matched. It matched because pxx applied no class-level
helper anywhere — there was no exclusion rule doing the work, just nothing
happening. TTest2.CS in the same program answered 3 as well, against fpc's 4,
which is what settles it.
bug-p-a-generic-routine-body-does-not-see-its-own-units-class-helper fixed
class-level helper dispatch (four member-lookup loops, two of which never asked
ClassHelperRecFor). The moment helpers were applied at all, this row flipped to
4 with nothing to stop it — which was predicted before the fix landed and
pinned at 3 in its own commit (17a0e4bd6) so the flip would read as a defect
revealed rather than caused. frankS called it; the pin is theirs.
The constraint on any fix
Do not narrow helper dispatch. Seven rows in the same file depend on
class-level helpers being applied: gen-TTest (2), inunit (2), plain-TTest
(2), plain-TTest2 (4), classfn-nonstatic (20), instance (400),
stmt-touch (2). A fix that makes gen-TTest2 answer 3 by applying fewer
helpers takes gen-TTest down with it — gen-TTest is the same template and
must answer the TEMPLATE unit's helper.
So the fix is a SCOPE rule, not a dispatch rule: while resolving a specialized
body, helper lookup must see the helpers visible at the template's declaration
site, not those visible at the specialization site. gen-TTest = 2 and
gen-TTest2 = 3 are the same rule read on two inputs.
Corpus
library_candidates/fpc-testsuite/tests/test/tgenfunc19.pp asserts exactly these
two rows (Halt(1) on the first, Halt(2) on the second) and is currently
blocked on this one row alone.
Resolved 2026-09-09 — a scope rule, in two halves, both already in this tree
gen-TTest2 answers 3. All eight rows in
test/test_a_class_helper_on_a_class_level_method.pas now match fpc 3.2.2, and
the constraint held: nothing was narrowed.
Measured first, and it named the half nobody had looked at
PXXDBG=p.specunit on the fixture:
DoTest_TTest body-unit=-1 spec-host=-2 <- the PROGRAM
DoTest_TTest2 body-unit=-1 spec-host=-2
TTest.CS body-unit=60 spec-host=-2 <- the unit, correctly
A specialization's METHOD bodies have parsed as their DECLARING unit since
[[bug-p-a-generic-template-body-resolves-its-symbols-at-the-specialization-site]].
A generic ROUTINE's body never got that rule — it parsed as whoever
specialized it. Same concept, one arm missing, which is the double case
normalise-dont-special-case is about.
Half one — the body parses as its declaring unit
GenericFuncUnitIdx records where a generic routine was declared (the channel
TemplateUnitIdx already carries for a generic CLASS), NoteGFSpecBodyUnit
records it against the spliced routine's name at both splice sites, and
ParseSubroutine switches CurrentUnitIdx for the body exactly as the method
arm beside it does.
Keyed by NAME rather than held in a global because parsing one concrete body can
register another specialization — a generic routine that calls a second one — so
splice and parse do not nest predictably. FlushPendingFuncSpecializations
loops on a count its own body can grow for the same reason.
The lookup returns found separately from the unit, because a generic routine
declared by the MAIN PROGRAM records -1 legitimately and that is the common
case: a bare >= 0 test reads "no row" and "the program declared it" as one
answer.
Half two — helper lookup is visibility-aware at all
This half is the answer to "why did half one change nothing on its own", which it did: with the body parsing as unit 60, all eight rows were still unchanged.
FindHelperForType had no visibility test of any kind — a flat downward
scan of every helper ever registered, so any helper anywhere won. Invisible
until two scopes declare helpers for related classes, which is exactly this
fixture.
It now asks HelperRowVisibleHere. That is ClassRowVisibleHere's rule minus
one carve-out, and the carve-out is the whole reason it is a separate function:
ClassRowVisibleHere exempts rows whose unit is the SPECIALIZING scope, so that
rows the specialization itself minted still resolve inside a body parsed as
another unit (TFPGListEnumeratorSpec in fgl.pp). Those are SYNTHESIZED CLASS
rows. A class helper is never one — nothing a specializer mints is a
helper — so the exemption has no subject in helper lookup, and applying it
anyway is precisely what let the program's TTest2Helper reach inside a
template body imported from another unit.
The constraint held
gen-TTest = 2 and gen-TTest2 = 3 are ONE RULE ON TWO INPUTS, which is what
the ticket asked for: the template unit's own helper still reaches the body, the
specializing program's does not. Narrowing dispatch would have fixed the second
and broken the first. The seven rows that stand on helpers being applied —
inunit, plain-TTest, plain-TTest2, classfn-nonstatic, instance,
stmt-touch, gen-TTest — are unchanged, and plain-TTest2 still answers 4,
because in the PROGRAM's own scope the program's helper is visible and must
apply.
The mechanism frankZ asked be recorded as a hypothesis
NOT MEASURED, carried as their words: specialization is token splicing under
the alias's name, with the substituted body re-parsed at the specialization
site. It is consistent with everything above and with p.specsplice's output on
the sibling ticket, and nothing here establishes it — the two halves that fixed
this row were verified by their own before/after, not by that model.
Corpus row burned
tgenfunc19.pp is out of test/pascal-conformance/pxx.skip. Verified rather
than trusted: it prints Ok and exits 0 under pxx, and identically under fpc
3.2.2. Its two assertions are this ticket's two rows —
specialize DoTest<TTest> <> 2 -> Halt(1) and <TTest2> <> 3 -> Halt(2).
Gate GREEN, FPC seed canary PASS.
Log: fixed in compiler/symtab.inc + compiler/pasparser_proc.inc +
compiler/pasparser_generic.inc + compiler/defs.inc, commit f0aca9c59. That commit
also burns tgenfunc19.pp from test/pascal-conformance/pxx.skip and re-aims
the fixture; the close is this file's move to done/ in the same commit.
The half worth carrying out of this ticket
FindHelperForType having no visibility test of any kind is the finding to
flag, and not for this row's sake. frankZ's framing, which is better than the
one above: it is the same shape as the four-member-lookup-loops-documented-as-two
note one layer down — a question with no scope filter at all, sampling correct
because nothing had yet asked it from two places. It sat directly under the
class-helper dispatch work of the same morning.
ClassHelperRecFor's comment now says so, because a reader who finds the
four-loop note will otherwise take the scope question for settled: that
paragraph is about WHICH LOOPS ASK, never about WHOSE HELPERS ANSWER.