← board

Pascal: uses is transitive, so every unit's imports leak to its consumers

Repro — routines (pure Pascal, no NilPy involved)

unit priv;
interface
function Wrap(const s: AnsiString): AnsiString;
implementation
uses sysutils;                        { implementation section — private by any reading }
function Wrap(const s: AnsiString): AnsiString;
begin
  Result := Format('[%s]', [s]);
end;
end.
program tp;
uses priv;                            { sysutils appears NOWHERE in this program }
begin
  writeln(Wrap('a'));                 { [a]  — fine }
  writeln(IntToStr(9));               { 9    — WRONG: should be "undefined variable" }
end.

Observed with stable_linux_amd64/default/pinned. IntToStr resolves although the program never used sysutils, and although priv imported it in its implementation section.

Expected

Pascal's uses is not transitive, in EITHER section. If A uses B, a unit that uses A does not see B's names — it must list B itself. The section (interface vs implementation) controls circular-reference legality and whether B's types may appear in A's interface signatures; it is not what makes the import private. Non-transitivity is what does that, and pxx does not implement it.

Cause

One flat global namespace, for both axes:

The uses clause records dependency and parse order; it does not gate visibility.

What it costs today

Every consequence below is the same missing rule wearing a different hat:

  1. decide-class-namespace-scoping in full. tkinter's Canvas and reportlab's collide because both are in one namespace. With non-transitive uses they are simply different names in different scopes — no new syntax, no replaces declaration, no shared-name list.
  2. ClassNameIsDeliberatelyShared exists only because of this. With real scoping, pylib uses sysutils (or the reverse) gives ONE Exception by construction and the list is deleted, along with the duplicated CreateFmt bodies described in that ticket.
  3. NilPy programs inherit Pascal's namespace. A .npy that reaches sysutils gets its exports unqualified, and Pascal is case-insensitive while Python is not, so format, date, time, trim, pos, copy, delete, insert and friends all shadow. Measured: print(format(255)) in a .npy that imports sysutils reports format(AnsiString, record) — a Pascal signature error against Python source — where without sysutils it correctly says undefined variable (format). User defs DO win, so only undefined names are affected, but the failure mode is a silent bind to Pascal semantics instead of a NameError.
  4. Related in kind, filed separately: [[bug-nilpy-stdlib-name-binds-pascal-unit]] (a Python stdlib import binding to a same-named RTL unit).

Sizing before committing

The tree currently gets names for free everywhere, and some of that WILL break — the size of the breakage decides whether this is one ticket or a campaign. Cheap measurement first: resolve exactly as today, but WARN whenever a name resolves through a unit that is not in the current unit's own uses (transitively via interface-section uses, per the real rule). Build the tree, count and classify the warnings. That is an afternoon and it turns the estimate into a number.

Expect the RTL itself to be the biggest consumer of the current laxity.

Fix shape

Visibility set per unit = its own uses + the interface-section uses of those units, transitively closed over INTERFACE sections only; implementation-section uses stay private to the importing unit. Applied uniformly to the routine table and to FindUClass/FindUField. Then the class-preference pass and its shared list both come out.

Gate

make test + make test-nilpy + self-host byte-identical + cross, with test/ cases for the routine repro above and for two units legitimately exporting the same class name. Land incrementally or behind a flag — this changes resolution for the whole tree and is exactly the kind of change that should not arrive as one commit.

Measurement step, as designed (2026-07-28)

Taking the ticket's own advice — resolve exactly as today, warn when a name resolves through a unit the current unit cannot legitimately see, then count. Shape of that work, so it can be picked up in one sitting:

  1. Edge table. No per-unit uses graph exists today; ParseUsesUnitBody records dependency and parse ORDER only. Add three parallel arrays — UsesFrom (Strs idx of the importing unit, -1 = main program), UsesTo, UsesInIface (Boolean) — appended wherever a uses clause names a unit. The section is known from where the clause is parsed; the parser has no interface/implementation flag today, so one has to be threaded through (the implementation keyword is matched by string compare at several sites, so the state is genuinely absent, not merely unnamed).

  2. VisibilityAllows(curUnit, declUnit). True when declUnit = curUnit, or declUnit is reachable from curUnit by one edge of EITHER section followed by INTERFACE-section edges only, transitively. That is the real Pascal rule: the section controls whether the import is re-exported, not whether it is legal.

  3. Warn, behind --warn-uses-leak. Opt-in so an ordinary build is unchanged and the measurement can run over the whole tree without touching any gate. Call it from the routine lookup the Pascal identifier path uses and from FindUClass.

  4. Count and classify over make test + make lib-test + the corpora. The expected shape of the answer: the RTL is the biggest consumer of the current laxity, and the interesting number is how many DISTINCT (importing unit, resolved unit) pairs appear rather than how many call sites.

Only after that number exists is it worth deciding between "one ticket" and "a campaign" — and the enforcement itself should land behind the same flag before it becomes the default.

Measurement step LANDED (2026-07-31)

Implemented exactly as designed above, plus one correction: InInterface (compiler/defs.inc) already existed as a global — the parser did NOT lack section state, contrary to point 1's premise. No new state-threading needed; ParseUsesUnitBody reads it directly at the point it interns each uses clause's target.

Known gap, not yet instrumented: the ticket's own headline repro (IntToStr(9) reached through priv's implementation-section uses sysutils) does not warn. FindProc is not the lookup a direct call site resolves through — per the ticket's own Cause section, that is IRFindProc1ByArgTk / the general call-classification path (MatchProcCall and friends), which is a different, more tangled entry point this pass didn't reach in one sitting. FindProc still caught real leaks (class lookups, @proc-style routine references), so the instrument is functional but under-counts — the real number is higher than what's below.

Counts, sample run (not the full make test + make lib-test sweep the design called for — that's real time; this is the first 80 files of test/*.pas, 923 total, to unblock the sizing decision tonight):

Sizing verdict: this is a campaign, not one ticket — 81 distinct pairs from a fifth of the routine test corpus, before touching lib/rtl itself or NilPy's .npy corpus, and before the call-classification gap above is closed (which will only raise the count). Filed [[decide-pascal-uses-campaign-scope]] (Track U) with this data for the sizing/sequencing call — resolving this bug outright is not a one-sitting job and guessing the shape is exactly what Track U exists to prevent.

What ships now: the instrument itself (edge table, VisibilityAllows, --warn-uses-leak) — inert by default, safe to land, and the tool the campaign (whichever shape it takes) will keep using to track progress toward zero warnings.

2026-08-01 — correction: the builtin/builtinheap count was measuring the wrong thing

The "hundreds of files need an explicit uses builtin[heap]" framing above is wrong. builtin/builtinheap are pxx's own equivalent of Pascal's System unit — compiler/builtin/builtin.pas says so directly ("System.X — pxx has no separate System unit (System IS the builtin layer)"), and every Pascal dialect auto-includes System everywhere with no uses needed. That's by design, not a leak. VisibilityAllows (compiler/symtab.inc) has no special case for this — it counts builtin/builtinheap references the same as any other unit pair, so the 6894/4522/4145/etc. hit counts dominating the sample are measurement noise from a gap in the instrument, not real scope. The fix: special-case builtin/builtinheap as implicitly-always-visible in VisibilityAllows, excluded from leak detection entirely, before trusting any future count.

Once that's fixed, the real remaining leak count (e.g. pylib -> sysutils, 1717 hits) is what's actually left — and even that one is smaller than it looks: verified directly that pylib.pas's own uses clause never mentions sysutils (only uses pypal, and pypal itself uses nothing). Most of that count is genuinely accidental leakage that will close on its own once real scoping lands. The one deliberate exception is pylib. Exception merging with sysutils.Exception (CreateFmt... "IMPLEMENTED BY sysutils") — already anticipated by [[decide-class-namespace-scoping]]'s resolution ("whichever of pylib/ sysutils imports the other gets that one class by construction"): pylib needs one explicit uses sysutils added to keep that intentional merge working, everything else closes for free. Guiding principle for whatever internal uses wiring the fix ends up needing: what matters is that USER programs get a clean namespace — internal RTL-to-RTL sharing (like the Exception merge) is fine as long as it's deliberate and doesn't leak unrelated surface to callers.

Folded into [[decide-pascal-uses-campaign-scope]] for the actual sizing/sequencing call, corrected.

Log

2026-08-01 — instrument fixed, TRUE count measured over the whole corpus

The 2026-08-01 correction above was right, and there was a SECOND artifact of the same kind underneath it. Both are now fixed in the instrument, and the sweep was re-run over all 934 test/*.pas (not the 80-file sample the sizing decision was made on).

Two ambient-System artifacts, both removed

  1. builtin/builtinheap — pxx's System unit, injected by ParseProgram (ParseUsesUnit('builtinheap') / ('builtin'), compiler/parser.inc), never written by a user clause, so the edge table structurally could not show them reachable. Fixed: UnitIsAmbient in compiler/symtab.inc, consulted by VisibilityAllows.
  2. TObject/TGuid — found by classifying the leftover -> <program> bucket rather than assuming it was real. It was 100% these two names and nothing else (TObject 3601 hits, TGuid 1092). They are minted by RegisterBuiltinTObject/RegisterBuiltinTGuid, so AddUClass stamps them with whatever CurrentUnitIdx is at ParseProgram time (-1, "the program") — making every unit that names TObject look like it leaked through the program. Fixed: ClassNameIsAmbientIntrinsic, filtered by NAME (the rows carry no marker, and adding a parallel array for an opt-in read-only instrument is not worth the shared-state churn).

Note -1 is NOT blanket-excluded: a genuinely program-declared class referenced from a unit IS a real leak and must still be reported.

The true number

Full corpus, 934 files, self-hosted binary at da085e9de + the instrument fix (snapshot binary, so no mid-sweep rebuild could drift it — the first attempt at this measurement WAS corrupted that way and was discarded):

Top pairs:

pair hits
pylib -> sysutils 2628
dns_wire_core -> sysutils 570
x509 -> sysutils 265
ecdsa_p256 -> rsa 170
platform -> pylib 143
platform_backend -> pylib 143
pylib -> textfile 105
ed25519 -> x25519 102
sha512 -> sha256 100
zlib -> sysutils 98

…then a long tail in the tens (the full 35 are all RTL-to-RTL pairs of this shape).

What this means for the campaign

The "hundreds of files, mechanical uses builtin[heap] additions" framing is gone entirely — that work does not exist, it was the artifact. What remains is 35 RTL-internal unit pairs, each needing either an explicit uses (where the dependency is deliberate, e.g. the pylib/sysutils Exception merge) or nothing at all (where it is accidental and closes for free once real scoping lands). No user-program-facing leak appears in the corpus at all.

That is a tractable, boring list — not a campaign. Folded back into [[decide-pascal-uses-campaign-scope]] for the re-sizing call.