← board

uses a, b — pxx picks the first unit's routine, FPC picks the last

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:

  1. The compiler cannot compile itself.

    pascal26:18218: error: set item must be one character
      near: EmitAsmX64 >>> sub rsp, 16  movsd [rsp], xmm0
    

    EmitAsmX64 has two overloads, array of const and AnsiString. The parser calls FindProc and reads the returned proc's signature to decide whether [...] is an open-array constructor or a set. Preferring the last entry handed back the AnsiString overload, so [...] parsed as a set.

  2. The NilPy stdlib segfaults. test_nilpy_repr_and_range_consumers.npy dies at sum(range(i)). pyparser.inc infers expression types by reading Procs[procIdx].RetType off whatever FindProc returns; 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:

  1. Give the call-binding path its own lookup that knows uses-order, and leave FindProc's representative selection alone. Most correct, most work.
  2. Record a uses-order sequence per unit and rank by it only where a call is actually being bound (MatchProcCall), never in FindProc.
  3. 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:

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:

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

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".

Log

Resolution (2026-08-10) — hiding as candidate REMOVAL, both facets

Built exactly as the 2026-08-06 investigation specified: candidate removal in MatchElig, never ranking in FindProc.

Two things the plan did not anticipate

1. The two call shapes bind through DIFFERENT code. Fixing MatchElig alone left WhoP(1) answering B while a bare Who still answered A — a parameterless reference binds straight off FindProc, which the plan (correctly) forbade touching. It needed its own binding query, FindProcBound, that ranks candidates while leaving the representative alone. Had only the parameterised half been tested, this would have shipped half-fixed.

2. Hiding must NOT apply to a QUALIFIED call. System.Random(i + 1) started reaching the used unit's Random instead of the builtin, because the unit outranks it — caught by test_builtin_name_demote in test-core, not by the quick tier. A qualified call has already named its scope; hiding only answers "which declaration does a bare name see". Gated on demote, which MatchProcCall already receives as qUnit = -1, so no new parameter.

The ticket's premise, corrected

It states pxx "ignores uses order entirely and takes whichever unit registered first". It does not — it consistently took the first unit in the clause where FPC takes the last. Symmetric and order-sensitive, just inverted. Worth correcting because "ignores order" points an investigation at registration order rather than at direction.

Measured against FPC

FPC pxx now pinned
uses shadow_a, shadow_b B B B B A A
uses shadow_b, shadow_a A A A A B B
program's IntToStr vs sysutils' MINE:5 MINE:5 5
System.Random vs unit's Random sys-ok sys-ok sys-ok

Both facets the promotion note named — uses-order AND the convertible-argument case — are fixed by the one rule, as predicted.

Test material restored

test/shadow_a.pas, test/shadow_b.pas, test/test_shadow_last_uses_wins.pas and test/test_shadow_first_uses_hidden.pas, wired into the Makefile. Both clause orders and both call shapes, because a rule that always picked one unit would pass either order alone, and the two shapes bind through different code.

Gate

--tier limited GREEN, 1726/1726 — the minimum the ticket demanded, since the quick tier passed both previously-broken versions. Plus gate.sh quick GREEN, make test-core exit 0 (the one that caught the qualified-call regression), make test-nilpy exit 0 (what the previous attempt broke), FPC seed canary PASS, self-host fixedpoint byte-identical.