← board

An external routine's pointer-param pointee is never recorded, so a class argument is accepted

Measured

Two programs differing in exactly one word ({$mode objfpc}-equivalent shape, TFoo = class, PInteger = ^Integer, call g(f) with f: TFoo):

declaration pxx @ 894e93867 (fixedpoint dc8546a1d159) FPC 3.2.2
procedure g(p: PInteger); + body rejectedargument types: (class) / candidates: g(Pointer) rejected
procedure g(p: PInteger); external 'c' name 'abs'; compiles clean, calls abs with an object pointer Error: Incompatible type for arg no. 1: Got "TFoo", expected "PInteger"

PXXDBG=p.ptrparam on each — this is the cause measured, not inferred:

external:  MATCH proc=g j=0 durable=0 sym=-1  symPtrElem=1 ...     <- no REG line at all
body:      REG   proc=g i=0 sym=94 elemtk=1 stored=1 durable=1
           MATCH proc=g j=0 durable=1 sym=94 symPtrElem=1 ...

durable=0 is Ord(tyUnknown). The guard at symtab.inc:8146-8168 reads that column and rejects a class argument only when the pointee is neither tyUnknown nor tyClass — so the sentinel permits it. sym=-1 closes the last exit: Params[j].SymIdx is -1 for an external, so there is no symbol to fall back to even unreliably.

Cause

ParseSubroutine has three param-registration paths, and the durable store is on only one of them:

path line writes ProcParamRecId / SetEnumId writes ProcParamPtrElemTk
external (then Exits ~1334) 1325-1328 yes no
forward / interface decl 1641-1644 yes no
body, where param syms are allocated 1978, 2081 yes yes

The external path exits at ~1334, ~750 lines before the store at pasparser_proc.inc:2081, and for an external routine the body path never runs at all — so the column is permanently the sentinel, not merely late. The forward path is filled in later when the body is parsed, so it is likely benign; it is listed because it is the same hole and a caller parsed between the two would see the sentinel.

Why it was not caught

bug-p-a-parameters-pointer-element-type-is-lost-between-registration-and-overload-matching fixed the scope-recycling fail-open by introducing this durable column, and defs.inc:2685 records that reasoning at length. The column was populated where the recycling bug was measured — the body path — and external, which has no body and no symbol, was never the shape under test. The sentinel doing double duty as "untyped pointer" is what makes both variants silent rather than loud.

Fix sketch

Mirror the two lines that already sit beside ProcParamRecId on the external path (1325) and the forward path (1641):

if (i < MAX_PROC_PARAMS) and (ptypes[i] = tyPointer) then
begin
  ProcParamPtrElemTk[procIdx * MAX_PROC_PARAMS + i] := Ord(ptypesPtrElemTk[i]);
  ProcParamPtrElemRec[procIdx * MAX_PROC_PARAMS + i] := ptypesPtrElemRec[i];
end;

The staging arrays are live and correct at both points (the harvest at :964 has already run for every param), so this is a copy, not a re-derivation.

Consider the root-cause form instead. Three registration paths each hand-copying an overlapping-but-different subset of ~20 ptypes* staging arrays into their ProcParam* twins is the shape normalise-dont-special-case.md describes: the second and third paths are the ones that stay broken. One PersistParamRow(procIdx, i) called from all three would delete the divergence rather than patch this instance of it — and would retire the same class of bug for ProcParamProcSig, ProcParamDynDepth and the rest, which should each be audited for the identical hole before choosing the microfix.

Repro

Self-contained — no fixture needed:

program extl;
type
  PInteger = ^Integer;
  TFoo = class
  end;
procedure g(p: PInteger); external 'c' name 'abs';
var f: TFoo;
begin
  f := TFoo.Create;
  g(f);
end.

Expect a compile error naming the incompatible argument; today it links and runs.

Gate

Track A: make compiler/pascal26 (self-host fixedpoint) + this repro rejected + the body-path control still rejected (guard not over-tightened) + an external routine with a correctly typed pointer argument still accepted.

2026-08-30 (frankS) — FIXED. The audit changed the fix: 17 missing columns, three defects, both directions

Binary: self-host fixedpoint 20bfc94a2c80; tools/gate.sh quick GREEN.

The hole was wider than filed, and counting it is what changed the answer

The ticket names ProcParamPtrElemTk on the external path. Sweeping every ProcParam* write in pasparser_proc.inc against the three paths:

path columns written (of ~20)
external (then Exits) 3 — RecId, SetEnumId, StrElemTk
forward / interface 14
body all

So external was missing 17, not 2. That is not a bigger version of the same bug — it is a different bug, because the omissions fail in opposite directions and only one of them is a fail-open:

repro before fpc 3.2.2
g(p: PInteger); external …; g(anObject) accepted rejected
g(p: PInteger); forward; + a call parsed before the body accepted rejected
g(a: Integer; b: Integer = 7); external …; g(1) rejected accepted

The third is the one the filed diagnosis could not have predicted: the same missing-column mechanism makes the overload matcher fail closed, so an external routine with a default argument was uncallable. A fix measured only by what it starts refusing would have missed half its own effect.

The forward path is NOT benign — the ticket's one wrong prediction

The ticket reasoned it was "likely benign, since the forward path is filled in later when the body is parsed." Measured false:

procedure g(p: PInteger); forward;
procedure caller; begin g(someObject); end;   { parsed FIRST — accepted }
procedure g(p: PInteger); begin end;

"Later" is after any caller parsed in between — and a forward declaration exists precisely so that something may be parsed there. That is the one assumption this path may not make.

What landed, and what deliberately did not

All three copies now write the full row, with the canonical explanation at the body path's copy (search THE DURABLE PARAM ROW) and pointers from the other two. The forward path also adopted the body path's write-only-true rule for defaults, which it lacked — a body repeats the parameter list without them.

The collapse into one PersistParamRow was written and does not compile: nested routine: capture of fixed-size array 'pconst' not yet supported. The 21 staging arrays are fixed-size locals, and they cannot become globals either because ParseSubroutine is re-entrant (pasparser_call.inc:272 calls it from an anonymous-method body). Filed as [[refactor-a-the-durable-param-row-is-hand-copied-on-three-registration-paths]], blocked on [[bug-a-a-nested-routine-cannot-capture-a-fixed-size-array]], with the 23-var-param alternative recorded as rejected — 15 same-typed array params means a transposition type-checks, trading a silent missing column for a silent wrong one. Not reshaped around, per the platonic-code rule.

Evidence

test_param_row_external_forward_fail.pas — 3 rows, same three lines fpc rejects, with the body path's row kept as the arm that already worked. test_param_row_external_forward_ok.pas compiles and runs, matching fpc's stdout exactly across defaults, pointee, rec id, set enum id, string element width, proc signature and a var dynamic array.

Both non-vacuous against pinned: it rejects 1 of the 3 fail rows, and cannot compile the ok half at all.

One divergence found and not filed as this ticket's business

external 'c' links under FPC and fails at load time under pxx (cannot open shared object file: c); external 'libc.so.6' works in both, and is what the repo already uses everywhere. Tests use the working spelling. Noted here rather than filed — it is a soname-resolution question, not a param-row one.

Log