← board

A forward declaration does not bind a differently-cased body

Found 2026-08-29 by frankA while re-measuring [[feature-pascal-corpus-expansion]] after walls 6 and 7 closed.

Repro — three shapes, one defect

{$MODE DELPHI}
program b;
type TR = record F: Pointer; end;
function Bar(a: Integer): Boolean; forward;
function bar(a: Integer): Boolean;          { body in a DIFFERENT case }
begin Result := a > 0; end;
const V: TR = (F: @Bar);
begin WriteLn(V.F <> nil); end.
shape pxx before fpc
direct call Bar(1) error: unresolved forward: Bar TRUE
@Bar in a typed const error: @Bar: the address of a routine with no body was taken TRUE
@Bar in a statement worked TRUE
matching case (control) worked TRUE

Cause

FindProcOverloadRec — the declaration-time matcher that decides whether a body REUSES an existing proc entry or registers a new one — compared names exactly:

if (ProcUnitIdx[i] = CurrentUnitIdx) and (Procs[i].Name = name) then

Every other proc lookup in symtab.inc carries the pair (Procs[i].Name = name) or ((not ProcCaseSensitive[i]) and CaseEqual(...)) — four sites (3618, 9108, 9127, 9144). This one had only the exact half, so it is the one arm of a double case that was never fixed — the shape devdocs/dev/normalise-dont-special-case.md is about.

The hash chain was never the problem and the fix does not touch it: NameFoldHash already folds A..Z, and ProcChainHead's own comment says the chain holds every proc with the same FOLDED name and that "callers still do their own exact/CaseEqual compare". Both spellings were always in the same bucket; only this compare rejected them.

ProcCaseSensitive is what keeps this scoped: NilPy and C routines set it True, so foo and Foo remain two routines there, which those languages require.

Why it was expensive to find, and worth recording

The diagnostic accuses the wrong construct, and which wrong construct depends on the reference. The corpus failure was @TEquals.Class: the address of a routine with no body was taken — an ELF-writer error, pointing at address-taking, in a unit whose declaration is &Class and whose body is &class. The natural first hypotheses, both wrong and both cheap to test:

  1. the & escape is case-sensitive — refuted: escaped-with-matching-case works, and unescaped-with-mismatched-case fails, so & is irrelevant;
  2. the const path resolves methods differently from the expression path — refuted: both call FindUMeth then UMthProc_[mi], and moving the const after the body changes nothing.

What actually distinguishes the working statement case from the failing const case is not the const at all — it is that the statement form in the corpus-like shapes reached a proc that had been re-bound, while the const captured the declaration. Dropping the class entirely and using a plain forward routine produced unresolved forward: Bar, which names the defect outright. The minimal repro was the measurement that mattered; every hypothesis formed from the corpus error was wrong.

Fix

compiler/symtab.incFindProcOverloadRec gains the CaseEqual arm. Six lines plus the comment. No lexer change, no backend, no IR.

Verification

Corpus effect — this is what it was found for

generics.defaults.pas (3,358 lines) now compiles END TO END. It was blocked at the first @TEquals.&Class in the VMT const table; before this session it had never got past its own declaration section's const block.

generics.collections.pas advances to a new and unrelated wall: unknown type: TKey at generics.defaults.pas:790 — a generic type parameter out of scope. Recorded on the umbrella as the next rung; not this ticket.