uses a, b — pxx picks the first unit's routine, FPC picks the last
- Type: bug — FPC divergence. Track P.
- Opened: 2026-08-05
- Split from: [[bug-p-program-function-does-not-shadow-used-unit]], whose program-vs-unit half is fixed. That ticket guessed "likely one fix, not two"; it is two, and this is the harder one.
Symptom
unit shadow_a; ... function Who: AnsiString; begin Who := 'A'; end;
unit shadow_b; ... function Who: AnsiString; begin Who := 'B'; end;
program t; uses shadow_a, shadow_b; begin writeln(Who); end.
FPC : B (last uses wins)
pxx : A
and symmetrically uses shadow_b, shadow_a gives FPC A, pxx B... no —
pxx gives A there too, i.e. pxx ignores uses order entirely and takes
whichever unit registered first.
Two units exporting the same routine is legal Pascal and FPC accepts it silently, so this is a real divergence, not a diagnostic gap.
Why the obvious fix is wrong — measured, twice
FindProc walks a hash chain that is FIFO = registration order, and used units
register before the compiling scope. The one-line fix is to prefer the last
match. Do not do this. It was tried and it broke two unrelated things:
-
The compiler cannot compile itself.
pascal26:18218: error: set item must be one character near: EmitAsmX64 >>> sub rsp, 16 movsd [rsp], xmm0EmitAsmX64has two overloads,array of constandAnsiString. The parser callsFindProcand reads the returned proc's signature to decide whether[...]is an open-array constructor or a set. Preferring the last entry handed back theAnsiStringoverload, so[...]parsed as a set. -
The NilPy stdlib segfaults.
test_nilpy_repr_and_range_consumers.npydies atsum(range(i)).pyparser.incinfers expression types by readingProcs[procIdx].RetTypeoff whateverFindProcreturns; a different scope's entry gave the wrong type and the wrong type reached codegen. Note the failure mode — a runtime segfault far from the cause, exactly the class the debugging playbook is about.
The root of both: FindProc returns a representative of a same-named set,
and callers read types and signatures off it. It is not a pure "which one
does this call bind to" query, so changing its ranking changes parsing and
inference too.
What that implies for a real fix
Ranking inside FindProc is the wrong layer. Plausible directions:
- Give the call-binding path its own lookup that knows uses-order, and leave
FindProc's representative selection alone. Most correct, most work. - Record a uses-order sequence per unit and rank by it only where a call is
actually being bound (
MatchProcCall), never inFindProc. - Leave it.
uses-order shadowing between two units is rare next to the program-vs-unit case, which is already fixed.
Whoever takes this should assume any FindProc ranking change needs the
limited tier at minimum — the quick tier passed both broken versions above.
Gate
uses a, b binds b's routine and uses b, a binds a's, matching FPC; the
NilPy suite and self-host both stay green. Test material to restore:
test_shadow_last_uses_wins.pas with shadow_a.pas / shadow_b.pas (removed
when this half was split out; the program-vs-unit test remains as
test/test_shadow_program_over_unit.pas).
2026-08-06 — this is one facet of a single rule, not its own problem
Reframed with the user. uses a, b taking the first unit is not a separate
defect from "a program's routine does not shadow a used unit's" — both are the
same missing rule:
A declaration hides a same-named one from an outer or earlier scope, unless marked
overload.
pxx behaves as if everything were overload — one flat set across scopes, with
registration order as the tiebreak. Under the hiding rule, the second uses
declares into a later scope, so it hides the first, and this ticket's symptom
disappears without a uses-order-specific mechanism.
So do not fix this in isolation. A bespoke uses-order ranking would be a third mechanism sitting next to the two that already exist (name mangling for nested routines, current-scope preference for exact matches), and it would be invisible to anyone reading either FPC's rule or ours.
Now blocked on [[decide-scope-hiding-vs-flat-overload-set]], which also carries
the measured reason the one-line "prefer the last chain entry" fix is wrong —
FindProc returns an overload-set representative that the parser reads
signatures off and NilPy reads return types off, so ranking there breaks the
self-compile and the NilPy stdlib. Hiding is candidate removal, which is a
different and probably safer change.
The repro and test material in this ticket stay valid and are what the decision should be gated against.
2026-08-06 — UNBLOCKED and promoted: this is now the implementation ticket
[[decide-scope-hiding-vs-flat-overload-set]] is decided: hiding becomes the
default, everywhere — no flag, no {$mode} gate. So this ticket stops being
"uses-order" and becomes the single implementation of the rule, covering both
remaining facets:
uses a, bbinds b's routine,uses b, abinds a's (this ticket's original symptom);IntToStr(5)reaches the program'sInt64declaration rather than sysutils' equally-convertible one (the convertible-argument case split out of [[bug-p-program-function-does-not-shadow-used-unit]]).
One rule fixes both. Prio raised 35 -> 60 to match the decision.
Build it as candidate REMOVAL, not ranking
This is the part that has already gone wrong once. Do not rank entries inside
FindProc's chain — build the candidate set with hidden declarations excluded,
then resolve normally. Ranking broke the self-compile and the NilPy stdlib
(details above), and both survived gate.sh quick.
Same-scope declarations do not hide each other — they are overloads. That is
what keeps EmitAsmX64's array of const / AnsiString pair working.
Measure NilPy before believing any estimate
pyparser.inc infers expression types from Procs[procIdx].RetType off
whatever FindProc returns, and hiding can change which procs are candidates
across pylib / pyeval / builtin. That is exactly where sum(range(i))
segfaulted. --tier limited minimum.
2026-08-06 — INVESTIGATION (no code changed): the insertion point and the rank source
Spent a session reading rather than editing, because this ticket has already broken the self-compile and the NilPy stdlib once and the expensive part is knowing where the removal goes. Three findings, all read off the source.
1. MatchElig is the candidate-removal hook, and it already does this once
compiler/symtab.inc's
function MatchElig(idx: Integer; const name: AnsiString; demote: Boolean;
bIdx: Integer; userOnly: Boolean): Boolean;
is the shared eligibility predicate — 9 call sites, one per matching phase
(exact, compatible, lifting, …). Adding the hiding test there removes hidden
declarations from the candidate set in every phase at once, which is exactly
what the decision demands, and it never touches FindProc's representative. That
is the whole reason ranking failed before: FindProc is a different query.
Better still, the shape is already implemented next door. userOnly /
PyUserShadowsProc is name-level candidate removal for NilPy: a module-level
def sorted(x) REPLACES pylib's, decided by NAME rather than argument fit,
"and it demotes ALL of the unit's overloads together". Pascal scope hiding is the
same rule with a scope rank in place of "declared by the main program". So this is
not a new mechanism — it is a third instance of one that exists twice
(demote for builtin-vs-unit, userOnly for NilPy).
2. The uses-order rank exists: CompiledUnits[]
CompiledUnits : array[0..255] of Integer / CompiledUnitCount (defs.inc ~1857)
records unit indices in compile order, and for a uses a, b clause that IS
the uses order — a is compiled before b. So the rank the ticket assumed had
to be invented is already recorded:
- compiling scope (
ProcUnitIdx = CurrentUnitIdx) → highest; - otherwise position in
CompiledUnits→ later = higher; - builtin unit → lowest.
Note the main program is ProcUnitIdx = -1 and CurrentUnitIdx = -1 while not
parsing an imported unit, so the "current scope" arm already falls out of the
existing comparison ProcUnitIdx[i] = CurrentUnitIdx used by FindProc and
Phase 1 of MatchProcCall.
3. What the implementation has to get right
- Compute the winning rank per NAME, once, then reject lower-ranked
candidates in
MatchElig. Do NOT rank inside a phase's loop — that is ranking again, and same-scope overloads must all survive together. overloadis the exemption. A declaration markedoverloadjoins rather than hides; that is what keepsEmitAsmX64'sarray of const/AnsiStringpair alive, and same-scope declarations never hide each other anyway.- Empty-set guard. If removal empties the candidate set the call should fail as "no overload matches", not silently fall through to a lower scope — but check that against FPC before choosing, since a hidden-but-only-candidate case is precisely where a wrong choice becomes a new silent divergence.
Not started, deliberately
The change itself is high-blast-radius (it re-decides binding across lib/rtl,
pylib, pyeval and builtin) and the decision requires --tier limited as the
minimum evidence, so each iteration is a ~10-minute measurement. Starting it
without room to finish would leave a half-applied Track A change in the tree,
which tools/progress.sh check treats as critical — worse than not starting.
Left in the backlog with the ground above under it. A session that picks this up starts at "add the rank + wire it into MatchElig", not at "where does this go".