← board

A call chained onto a class-method result is silently dropped (class-NAME receiver)

Found reducing rung 3's wall ([[feature-pascal-corpus-generics]]), alongside [[bug-p-a-class-method-call-keeps-the-receivers-class]] — which is fixed, and does not fix this one.

Symptom: a dropped call, not a diagnostic

b := TFactory.GetObj.Inst(14);     { fpc: 42 }

pxx compiles it and yields the object pointerPtrUInt(Pointer(TSvc)) matched the answer exactly in a side-by-side probe, so the trailing .Inst(14) is dropped and the expression is just TFactory.GetObj. A non-existent member compiles too:

b := TFactory.GetObj.NoSuchXY;     { fpc: identifier idents no member }

Silent wrong value, so this is a bug-, not compat.

Not metaclass-specific and not virtual-specific — both a metaclass-returning and an ordinary-class-returning class function fail, virtual or not. The variable is the receiver:

chain pxx fpc
f.MakeI.Inst(14) — instance receiver 42 42
f.MakeC.Inst(14) — instance recv, class fn 42 (after the sibling fix) 42
TFactory.MakeC.Inst(14)class-name receiver garbage 42
either, split via a temp var 42 42

Ruled out — do not re-walk this

The Exit in ParseLValueAST's class-name static-method arm is NOT it. That arm ends Result := mcallNode; Exit; where both instance-reached arms Continue the selector loop, which reads exactly like the bug and is the first thing anyone will try. It was tried: replacing the Exit with node := mcallNode so control falls into the loop left PXXDBG=a.ast:drive byte-identical, and the behaviour unchanged. The .Inst(14) tokens are consumed somewhere else entirely and that arm is not on this path. The change was reverted rather than kept as a plausible-looking no-op.

So the next session's first job is locating the path, not fixing one. Start by finding what consumes the trailing tokens — a class name at the head of a factor may not enter ParseLValueAST at all. ParseClassRecordSelectors / ParseClassRefOpTail and the call-result tail near pasparser_lval.inc:4882 (which is guarded on (tk = tyClass) or (tk = tyRecord) and passes ProcRetRecId[procIdx]) are unexamined candidates.

Repro

TFactory.GetObj.Inst(14) where GetObj is class function ... : TSvc and Inst is any member of TSvc; ~20 lines, fpc-oracled. Add a member that exists on the FACTORY but not on the returned class to check for the wrong-class resolution the sibling ticket fixed — it must stay rejected.

Resolved — two arms, two entries, one bug

TClassName.ClassMethod(...) is built in two places, and each returned the bare call without applying the selector tail:

entry file shape it serves
ParseFactorCore pasparser_expr.inc (~7262) expression position — b := TFactory.MakeC.Inst(14)
ParseLValueAST pasparser_lval.inc (~1138) statement position — TFactory.MakeC.Bump;

Both now apply the tail. ParseFactorCore routes the result through ParseClassRecordSelectors when it is a class/record and a . follows; ParseLValueAST simply stops Exit-ing and falls into the selector loop already sitting eleven lines below it — the three lines above that Exit had already computed the result's tk and recName for it.

Why the trailing tokens vanished without a diagnostic. They were left in the stream, and ParseStatementAST's catch-all — pasparser_stmt.inc:6951, while not (CurTok.Kind in [tkSemicolon,tkEnd,tkElse,tkUntil,tkEOF]) do Next — ate them. That is the same swallow documented for F1(1) := 3; in the comment directly beneath it and for GetRegistry.AddTest(X); at pasparser_stmt.inc:5995, whose remedy was to delegate the statement to the expression parser. Third instance of one family: a statement-level skip loop turns every unconsumed tail into silence, which is also the whole reason TFactory.MakeC.NoSuchXY compiled — a tail that is skipped is never resolved.

About the refutation in this ticket — it was true, and it was not the whole claim

The prior session's finding stands and I reproduced it: patching the Exit in ParseLValueAST's class-name arm leaves the repro byte-identical. I confirmed it with the check the original lacked — the compiler binary's sha256 actually changed (b2dff2c3f0f0f1c2), so it was a real no-op and not a stale-binary artifact.

But "patching this arm changes nothing" and "this arm is not defective" are different claims, and only the first was measured. The arm is defective — for a shape the repro did not contain. The repro was b := TFactory.GetObj.Inst(14), an assignment RHS, which never enters ParseLValueAST; the statement spelling TFactory.GetObj.Bump; does, and was still silently dropping the call after the expression arm was fixed. A refutation is scoped to the shape that was tested. Same family as the rest of this bug: a no-op patch and a correct arm produce identical evidence, so the negative result cannot distinguish them.

devdocs/dev/normalise-dont-special-case.md's rule is what caught it — grep for the sibling before closing. Here the sibling was not a second shape, it was a second entry point for the same shape.

Verification

test/test_class_name_receiver_chain.pas (+ .expected, wired into the Makefile beside test_class_method_result_type) covers all ten spellings: method-with-arg, parameterless method, field, deep chain, record result, two calls in one expression, no tail at all, the parenthesised form, the temp-var split, and statement position. The .expected was generated from fpc, not hand-written, and the test was confirmed RED on the pre-fix pinned binary and GREEN after.

Rejections hold, and the sibling fix's guard with them: TFactory.MakeC.NoSuchXY and a member that exists on the factory but not on the returned class are both now "no such member on this record/class" at the right line, exit 1, no binary — where fpc says identifier idents no member.

Gate: make compiler/pascal26 fixedpoint (converged after 1 round(s)) + tools/gate.sh quick. That gate reported one FAIL, the FPC seed canary, which is not this change: rparser.inc(1416,12) Error: Identifier not found "RExprRecId" — used at :1416, defined at :1754, no forward. Measured, not assumed: stashing this diff and rebuilding the seed at clean HEAD reproduces it. Track R's, filed separately.

Log