← board

NodeMetaclassCi does not know a virtual class-method call

Filed by frankA (Track P) under the frontend rule: the fix is in compiler/symtab.inc, which is Track A's shared ground, so this is filed rather than fixed. It is the current wall of [[feature-pascal-corpus-generics]] (rung 3) at generics.defaults.pas:3341.

The corpus line

Exit(AFactory.GetHashService.LookupEqualityComparer(ATypeInfo, ASize));

AFactory: THashFactoryClass (class of THashFactory), and GetHashService: THashServiceClass; virtual; abstract — a virtual class function returning a metaclass. Reduced to 30 lines, fpc-oracled (prints 42):

type
  TSvc = class class function Lookup(n: Integer): Integer; static; end;
  TSvcClass = class of TSvc;
  TFactory = class class function GetSvc: TSvcClass; virtual; end;
function Drive(AFactory: TFactoryClass): Integer;
begin
  Result := AFactory.GetSvc.Lookup(21);     { "Lookup": a pointer has no members }
end;

Root cause

NodeMetaclassCi (symtab.inc) answers "which class does this node's metaclass value refer to" for five spellings, the fifth being a function result:

  else if ASTKind[node] = AN_CALL then ...ProcRetPtrElemRec[si]...

But GenMakeStaticMethodCall (pasparser_call.inc:1361) builds AN_CALL and then, for a virtual class method, replaces the kind:

  if GenVirtSlot >= 0 then
  begin
    ASTKind[callNode] := AN_CLASS_VIRTUAL_CALL;   { 88, not 8 }

so the predicate returns -1, the metaclass branch in ParseLValueAST is skipped, and the chain falls through to the tk = tyPointer / recName = REC_NONE guard, which reports a pointer has no members.

The predicate's comment already frames the fix: "The first four were each taught to be a receiver one ticket at a time... One predicate instead of five arms, so the next spelling is added once." This is the sixth spelling — it belongs in the existing AN_CALL arm, not in a new branch.

Evidence — virtual is the whole variable

fpc answers 42 for every row.

receiver GetSvc pxx
class of value virtual REFUSED a pointer has no members
class of value non-virtual 42
instance virtual REFUSED no such member on this record/class
instance non-virtual 42

Non-virtual leaves the node as AN_CALL and works; virtual rewrites it to AN_CLASS_VIRTUAL_CALL and fails. PXXDBG=a.ast on the working arm confirms kind=8 for the intermediate call. AN_CALL = 8 / AN_CLASS_VIRTUAL_CALL = 88 (defs.inc:332, :512).

The instance row's different message is probably the same missing identity surfacing at a different diagnosis site, but that is inference, not measurement — confirm it with the fix rather than assuming it.

Gate

Track A's: make test + self-host byte-identical. The 30-line repro above is the regression test; keep the non-virtual arm in it as the control.

Resolved 2026-08-29 (frankA, sole-A, combined-track self-resolve)

The ticket named one missing row and there were three. Filed as "the sixth spelling"; it is the sixth, seventh and eighth. Measured before touching the predicate, each shape in its own program because the compiler aborts on the first error and one refusal hides the rest:

spelling of the metaclass-returning call node kind before after
plain function AN_CALL (8) 42 42
non-virtual class method AN_CALL 42 42
non-virtual instance method AN_CALL 42 42
virtual class method AN_CLASS_VIRTUAL_CALL (88) REFUSED 42
virtual instance method AN_VIRTUAL_CALL (32) REFUSED 42
interface method AN_INTF_CALL (58) 4258231 — silent 42

fpc 3.2.2 answers 42 for every row. The interface row is the one the ticket missed entirely, and it was the silent one.

Was the predicate's premise right? Half.

The comment says the predicate exists so "the next spelling is added once", and the instruction was to flag it if adding one needed more than an enumeration entry.

For the two virtual spellings the premise held exactly — one condition, three node kinds, no other change, both fixed.

For the interface spelling it did not, and that is the bigger finding. Adding AN_INTF_CALL to the predicate changed nothing, because that path never reaches the predicate: the interface arm in pasparser_lval.inc ended

  node := mcallNode;
  Result := node; Exit;        { <-- drops every trailing selector }

so g.IGet.Lookup(21) never asked what g.IGet was — the .Lookup(21) was discarded and the expression evaluated to the intermediate result. A predicate cannot answer a question nobody asks it. Fixed by continuing the selector loop and setting tk/recName from the result, the same three lines the class-method and instance-method arms carry.

So the enumeration is the right design and was not the whole defect: one bug was a missing table row, the other was a caller that never consulted the table.

Why the row was under-specified

Not carelessness — a category slip. The five rows enumerate where the pointee is stored (Syms[].PtrElem*, AliasElem*, UFldPtrElem*, ProcRetPtrElem*), and on that axis "a function result" really is one row. But the row also has to match a node kind, and there are four kinds meaning "a call": one is built and then rewritten in place by whoever knows the dispatch — GenMakeStaticMethodCall swaps in AN_CLASS_VIRTUAL_CALL, the instance arm allocates AN_VIRTUAL_CALL, the interface arm AN_INTF_CALL. One axis was complete and the other was not.

All four keep the Procs[] index in ASTIVal — the vtable/IMT slot rides in ASTSOffset or ASTRight — which is why one lookup can serve them; that was checked against each builder before widening the condition, since indexing Procs[] with a vtable slot would have been a silent wrong answer of my own.

Sibling checked, per the standing rule

The obvious twin of "virtual class function returning a metaclass" is one returning an instance. Built the same six-spelling program with class of T replaced by T: all six rows correct, matching fpc. (Its interface row was broken before today too, and is fixed by the same selector-loop change.)

Corpus

generics.defaults.pas is past 3341; the next stop is a different failure class again — @TEquals.Class: the address of a routine with no body was taken. Trust near: over in: there, as this rung's notes say: in: named builtinheap.pas while near: showed generics.defaults tokens.

Files, and the boundary

compiler/symtab.inc (the predicate) and compiler/pasparser_lval.inc (the interface arm). No backend was touchedir_codegen_*.inc are the O campaign's and were not opened.

Gate

make compiler/pascal26 fixedpoint fd7a0e752dec, tools/forwardlint.py clean, gate.sh quick GREEN. test/test_metaclass_call_spellings.pas is fpc-oracled, carries the three always-working spellings as controls, and was confirmed refused on the baseline first (line 53).

Log