← board

Repro

program w; {$MODE OBJFPC}
type
  TS = class
    function Write(const Buffer; Count: Longint): Longint;
    procedure P;
  end;
function TS.Write(const Buffer; Count: Longint): Longint; begin Result := Count; end;
procedure TS.P;
var f: Text; s: AnsiString;
begin
  Assign(f, 'w.txt'); Rewrite(f); Write(f, 'payload'); Close(f);
  Assign(f, 'w.txt'); Reset(f); Readln(f, s); Close(f);
  writeln('got=[', s, ']');
end;
var s: TS; begin s := TS.Create; s.P; end.
compiler result
pxx HEAD compiles, runs, prints got=[], exit 0 — w.txt is created and empty
pxx pinned identical — pre-existing, not a regression
fpc 3.2.2 refuses: Wrong number of parameters specified for call to "Write"

Why it happens

pasparser_stmt.inc's tkwriteln/tkwrite/tkReadln/tkRead arm binds an unqualified call to a member of the enclosing class when FindUMethOverloadAhead / FindUMethArityStrict accept it. Neither asks whether the FIRST ARGUMENT IS A FILE HANDLE — the one fact that makes a call unambiguously the intrinsic, since no ordinary member takes a Text or a FileRec in that position by accident.

Write(f, 'payload') is two arguments and the member takes two, so the arity gate passes and the type-directed FindUMethOverloadAhead is not decisive enough to reject it (const Buffer is an untyped formal and accepts anything).

What makes this the bad kind of bug

We are neither compiler. FPC refuses; a fall-through-to-intrinsic design performs the write. We accept the program and perform a different operation, with the same exit code as success. Nothing in the output distinguishes it from a working program — the file exists, it is just empty. This is the shape CLAUDE.md calls out: "the expensive bugs here do not crash; they produce a plausible wrong value far from the cause."

The design fork, and a recommendation rather than a Track U ticket

FPC's rule is "member always wins, qualify with System.Write to reach the intrinsic". We already diverge from that deliberately and should not adopt it now: the arity fall-through was installed by [[bug-p-a-write-call-inside-a-method-named-write-binds-to-the-member-whatever-its-arity]] precisely so lib/rtl/configparser.pas — written in exactly this shape — would build, and FPC rejects that file outright.

So the consistent completion of the existing choice is route on the first argument: if it is a Text or a FileRec handle, it is the intrinsic, whatever any member's arity says. That rule needs no new policy, it makes the 2-argument case behave like the 3-argument case already does, and the parser already has both predicates — TextIOFileSym and FileIOFileSym — that answer exactly this question by lookahead, which is how the file arms of ParseReadArgsAST / ParsewriteArgsAST find their handle today.

Reaching a member that genuinely wants a file handle as its first parameter stays possible by qualifying (Self.Write(f, n)), which is what FPC makes you do for the intrinsic and is the same escape hatch pointed the other way.

Gate

The repro, asserting got=[payload], plus BOTH controls, because this sits between two mechanisms that can each eat the other:

test_read_write_as_method_name.pas documents this defect in the comment above its TReaderOnly class, which exists only because TStreamish cannot host the Text round-trip row while this bug is open. Fold that class back in when this is fixed — its presence is a marker.

Log

The oracle, added after landing — "writes something" and "writes the RIGHT BYTES" are different claims

Compiler 47618f77c240. The fix was landed on the strength of a round-trip returning payload, which is content and not bytes, and this source has no direct FPC oracle: fpc refuses to compile it, since it gives the member absolute priority. So the oracle was CONSTRUCTED rather than skipped.

Three programs, one emission — a string, a formatted Write(f, ' ', 42, ' ', 3.5:0:2), a bare Writeln(f), and a three-iteration Writeln loop:

program built by
A class DECLARES function Write(const Buffer; Count: Longint) pxx only (fpc refuses)
B identical emission, no member named Write pxx
C same source as B fpc 3.2.2
B == C   the intrinsic itself is right
A == B   shadowing changes nothing
A == C   the claim: 34 bytes, IDENTICAL

payload 42 3.50\nrow 1\nrow 2\nrow 3\n. Positive control: appending one byte to A makes it differ, so the comparison can fail.

Why the middle row is the load-bearing one. A == C alone would be satisfied by a fix that routed to the intrinsic and got the FORMATTING wrong in some way fpc happened to match. A == B says the shadowed and unshadowed paths produce the same bytes, i.e. the routing decision is the ONLY thing that differs between them — which is the actual claim this ticket makes. Checking only that the file was non-empty would have passed against a fix that wrote payload and dropped the formatted arguments.