← board

A generic method implementation is attributed by name, not arity

Measured at binary 417ee5636a72, HEAD 5acbe362b, and the age facts by git log -S.

The wall

$ pascal26 -Fu<rtl-generics/src> driver.pas    { driver: uses Generics.Collections; }
generics.defaults.pas:3250: error: undefined variable (TGOrdinalStringComparer)
  near: FOrdinal ) then FOrdinal := TGOrdinalStringComparer >>> < string ,
generics.defaults.pas:3250: error: expected '(' before ','
  near: then FOrdinal := TGOrdinalStringComparer < string >>> , THashFactory >

Read the near-window, not the message. T is substituted to string and THashFactory is NOT -- it is still the template's own parameter name. The source line is

class function TGStringComparer<T, THashFactory>.Ordinal: TCustomComparer<T>;
begin
  if not Assigned(FOrdinal) then
    FOrdinal := TGOrdinalStringComparer<T, THashFactory>.Create;

and it is fine. undefined variable is what the expression parser says once the < stops being a specialization opener and becomes less-than.

The cause

generics.defaults.pas declares, in one unit:

TGStringComparer<T, THashFactory> = class(TOrdinalComparer<T, THashFactory>) ... end;
TGStringComparer<T> = class(TGStringComparer<T, TDelphiQuadrupleHashFactory>);
TStringComparer = class(TGStringComparer<string>);

Legal, and four families in that file do it. Three sites pick the template by name and keep the LAST match:

site file what it decides
EmitLateNestedSpecDecls pasparser_generic.inc which template's method impls to SCAN for nested prerequisites
ScanDelphiMethodImplsForNestedSpecs pasparser_generic.inc which impl headers belong to that template
ParseSubroutine's X.M branch pasparser_proc.inc which template a body is BUFFERED against

PXXDBG=p.nspec says it in one line:

reg alias=TGOrdinalStringComparer$string$THashFactory
    under=TGStringComparer$string  tmplName=TGStringComparer  nsub=1  subs=T->string

A one-parameter specialization, scanning a two-parameter body, with a one-entry substitution set. That probe's own header already told us what such a line means: "an argument that comes out as a template PARAMETER name means the name was not in SpecSubNames here".

SpecTemplateIdx was added for exactly this -- SpecTemplateDeclUnit's header says "a name is not an identity ... which is the bug SpecTemplateIdx exists to fix" -- and EmitLateNestedSpecDecls kept the old lookup. A rule spelled per caller, failing by an absent copy.

Not a regression, and a revert will LIE about that

1c16d4523 (2026-09-09) is the natural suspect because the wall appeared right after it. It is not the cause: the EmitLateNestedSpecDecls loop is 3a011ed6f, 2026-08-29, and the ParseSubroutine loop is 951d9c9dd, 2026-08-20. 1c16d4523 moved the wall from collections.pas:120 to here, which made this defect REACHABLE for the first time.

So reverting 1c16d4523 makes the :3250 error disappear, and that means nothing -- the file stops earlier and never reaches the line. An attribution run over this must compare the CODE's age, not the error's presence.

What a fixer needs to know before starting

Making attribution arity-aware is straightforward -- the header's arity is knowable, because DelphiRewriteGenericUses deletes the <...> group and can record na beside GenMethImplSOff. Done that way, the corpus's near-window goes from < string , THashFactory > to < string , TDelphiQuadrupleHashFactory >: the substitution becomes correct.

And the error stays, one layer down, and a program that compiles today starts failing. This 30-line unit compiles at HEAD and does NOT with attribution fixed:

unit wunit;
{$MODE DELPHI}
interface
type
  THashA = class end;
  TBase<T> = class end;
  TStr<T, H> = class(TBase<T>)
    class function Ordinal: TBase<T>; static;
  end;
  TOrd<T, H> = class(TStr<T, H>) end;
  TStr<T> = class(TStr<T, THashA>);          { the arity overload }
  TOrd<T> = class(TOrd<T, THashA>);
  TStringComparer = class(TStr<string>);
implementation
class function TStr<T, H>.Ordinal: TBase<T>;
begin
  Result := TOrd<T, H>.Create;               { EXPRESSION position }
end;
end.

undefined variable (specialize), near Result := specialize >>> TOrd < string.

The alias TOrd$string$THashA IS minted (PXXDBG=p.mint:* shows it), and SpecializeToBuffer collapses such a group only when NestedSpecKnown(aliasNm) holds at splice time. In a type position it does; in this expression position it does not, the specialize token survives into the stream, and no expression parser arm consumes it. The two halves must land together -- attribution without the collapse trades a wrong substitution for a refused program.

Where to look for the second half: SpecializeToBuffer's NestedSpecGroup arm (pasparser_generic.inc), and whether the prerequisite is registered before or after the body is streamed for that specialization.

Held state

A partial patch -- arity recorded in a new GenMethImplNArgs, all three sites made arity-aware, SpecTemplateIdx used in EmitLateNestedSpecDecls -- exists in the frankS checkout as a scratch patch and is not on origin. It is the first half only, it makes the repro above fail, and it is deliberately unlanded. Its value is this write-up; do not go looking for the diff.

2026-09-09 (frankS, later) — the second half is NOT an expression-position gap, and I said it was

Measured at HEAD 7bbab967c with the four-site patch applied, probes added for this question and then removed with it.

Correction first

I wrote above that the second half is "a specialize NAME<args> group in EXPRESSION position inside a streamed body is never collapsed". That is the symptom, not the cause, and the cause is nothing to do with expression position. The group never carries the specialize marker in the first place, and NestedSpecGroup requires it, so the collapse arm cannot see the group at all — measured, gEnd=-1.

What the marker is missing FROM: a snapshot

DelphiRewriteGenericUses injects specialize into the MAIN token stream when a template is declared. BufferTemplateMethodsAhead copies method bodies into the template arena at SPECIALIZATION time. In generics.defaults.pas those two orders cross:

line  994   TStringComparer = class(TGStringComparer<string>);    <- specialization
line 1002   TGOrdinalStringComparer<T, THashFactory> = class(...) <- declaration
line 3250   FOrdinal := TGOrdinalStringComparer<T, THashFactory>.Create;

Two probes, one run:

PXXDBG p.ahead  buffer tmpl=TGStringComparer hdrline=3247 toks=37
                specialize-tokens=1 atparseline=994
PXXDBG p.dgen   inject specialize before TGOrdinalStringComparer tok=153453 line=3250

The body at :3247 is copied while the parser is at line 994 — eight lines before TGOrdinalStringComparer exists. Its own sweep injects the marker at :3250 afterwards, into the main stream, and nothing re-visits the copy. The arena holds one specialize (the return type's, injected by an earlier sweep) and not the one that matters.

A copy is a snapshot, and this one is taken mid-rewrite. The rewrite runs to a fixed point PER TEMPLATE, so "the stream has been rewritten" is only ever true of the templates declared so far — and buffering ahead exists precisely to get in front of the parser, i.e. in front of later declarations.

What this means for a fixer

One number withdrawn

I said earlier that uses Generics.Defaults alone compiled while uses Generics.Collections failed. At the current HEAD both fail at :3250 — I measured the first at binary 417ee5636a72, the tree has moved since, and the difference was never mine. Re-measure before quoting a driver-dependent wall on this rung.

Log

FIXED 2026-09-09 (frankS)

Both halves, together, because neither lands alone.

Attribution (four sites). Two now ask SpecTemplateIdx[si] — the template identity the specialization row was built from, which is what SpecTemplateDeclUnit's header says the field exists for — and two ask the new DelphiGenMethImplHdrOfTemplate, comparing the arity DelphiRewriteGenericUses records in GenMethImplNArgs when it deletes the <...> group. An unrecorded arity answers True, so objfpc headers (TFoo.M, nothing to strip) resolve exactly as before.

The snapshot. RefreshAheadBufferedMethod re-captures an ahead-buffered body at FLUSH time, keyed by source offset. Flush time is the point where every template the type section declares has swept; source offset is the stable key, because token indices move under the rewrite's own inserts. BufferGenericMethod said the ahead copy "is identical by construction" — that sentence was the bug.

The third thing, which only appeared once the first two were right. EmitLateNestedSpecDecls splices prerequisite declarations the parser has not reached, and FlushPendingClassSpecializations runs in the same call, so at splice time FindSpecialization cannot see them — by construction, not by timing. LateSpecEmitted records them for the collapse arm ALONE. NestedSpecKnown is deliberately not widened: it also decides whether a prerequisite still needs emitting, and answering True there for something merely queued would drop the declaration this list exists to remember.

Verified. test/test_a_generic_method_impl_binds_by_arity_not_name.pas with test/units/uarityoverload.pas prints 3 / 7, byte-matching fpc 3.2.2 -Mdelphi. Negative control (fix reverted, rebuilt): SizeOf: unknown type or variable on H, which is the mechanism itself. gate.sh quick GREEN. Conformance 423 pass / 0 fail / 42 gap — identical to a HEAD control run; the +2 against this morning's 421/44 is f0aca9c59 and 1c16d4523, not mine.

What this does NOT fix, measured at the same binary:

driver wall now
uses Generics.Defaults unresolved forward: TInstance.CreateSelector
uses Generics.Collections too many deferred specializations (TEnumerator$PT minted 55 times)

The second is [[bug-p-a-class-nested-type-as-a-specialization-argument-resolves-at-unit-scope]] territory — PT is a nested type named as a specialization argument — reached far more often now that the parse gets past :3250. Not investigated, and it may yet be an amplification of this change rather than the older defect. Say which before quoting it.