A member on a computed value silently reads the value's own bytes
Found 2026-08-25 while probing feature-p-delphi-string-helpers against fpc
3.2.2. Fixed the same session.
Repro
{$mode objfpc}{$H+}{$modeswitch typehelpers}
program one;
type TStrH = type helper for string function Twice: string; end;
function TStrH.Twice: string; begin Result := Self + Self; end;
var s: string;
function F: string; begin Result := 'q'; end;
begin
s := 'a';
Writeln(s.Twice.Twice); { fpc: aaaa pxx: 24929 }
Writeln(s.Twice.IsEmpty); { fpc: FALSE pxx: 24929 }
Writeln((F).Twice); { fpc: qq pxx: 113 }
end.
24929 is $6161 — 'aa' read as an Int32. 113 is ord('q'). Note the second
row: a DIFFERENT member gives the SAME number, because the trailing selector is
not merely mistyped, it is silently dropped.
Root cause
RecFieldType's not-found default is "tyInteger at offset 0". Four separate
copies of member dispatch fall through to it:
ParseSelectors(pasparser_lval.inc~2693)ParseClassRecordSelectors(~4065)PyParseClassRecordSelectors(NilPy's twin)- the grouped-postfix
.tail inParseFactorCore(pasparser_expr.inc~1116)
Copies 1 and 2 call RequireRecMember, which refused an unknown member — but
only when the receiver was a user class (recId >= REC_UCLASS_BASE). It said
nothing about recId = REC_NONE, i.e. "this is not a record or class at all",
which is exactly what a helper method's return type leaves behind: the selector
loop sets recId := REC_NONE for a non-class/record result and then keeps
looping. Copy 4 did not call RequireRecMember at all.
The earlier fix for the NAMED case
([[bug-p-a-member-on-a-scalar-silently-reads-the-values-own-bytes]]) was keyed
on ASTKind[node] = AN_IDENT deliberately — a tk-keyed version had refused
three working cast-then-deref programs (PPyVarRec(@v)^.Payload). That
narrowing was right, but "not a name" was then read as "leave it alone", and
the computed receiver kept the silent behaviour the named one had lost.
Fix
One arm in RequireRecMember (pasparser_call.inc) — recId = REC_NONE is
refused — plus the missing RequireRecMember call in copy 4. Every copy of
member dispatch already asks that function whether the member exists, so
asking it about REC_NONE too is one arm, not a fifth copy.
Deliberately NOT a tk-keyed check: recId is the thing the fallthrough is
about to index with, so keying the refusal on it cannot disagree with what the
code then does. That is why the three cast-then-deref programs are unaffected
— measured, not assumed: tools/lib_units_compile.py says 141 units
compile on pinned and on HEAD alike, and every examples/** program that
built before still builds (12 pre-existing failures, byte-identical list).
What this does NOT fix
Helper methods on a computed receiver stay a gap — FPC chains them fine, and
(F).Twice, 'xy'.Twice, Copy(s,1,1).Twice, s.Twice.Twice are all now
loud refusals rather than wrong numbers. The constructive half belongs to
[[feature-p-delphi-string-helpers]], whose scope section should grow a line
saying the receiver must be generalised past a declared variable. Verified the
same session: a plain variable receiver (s.Twice, s.IsEmpty) works,
cross-unit, and matches FPC byte for byte.
Verification
test/test_computed_member_fail.pas and
test/test_chained_helper_member_fail.pas, both wired into test-core next to
the two test_scalar_member_*_fail tests they extend.
make compiler/pascal26 converged in one round; tools/gate.sh quick GREEN;
fpc-testsuite unmoved.