Pascal: uses is transitive, so every unit's imports leak to its consumers
- Type: bug (name resolution / unit visibility) — Track A
- Status: done
- Opened: 2026-07-28, from [[decide-class-namespace-scoping]]. This is the root cause that ticket is a symptom of.
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:
- Routines — procedure lookup walks all units.
IRFindProc1ByArgTk(compiler/ir.inc) says so outright: "across ALL units. FindProcOverload is scoped to CurrentUnitIdx, so it cannot see pylib's pystr_of from the main program being lowered." - Classes —
FindUClass(compiler/symtab.inc:386) is first-match overUClsCount, with a unit-preference pass in front of it and a hard-coded exception list (ClassNameIsDeliberatelyShared,:331) to keep the preference from breakingException.
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:
decide-class-namespace-scopingin full. tkinter'sCanvasand reportlab's collide because both are in one namespace. With non-transitiveusesthey are simply different names in different scopes — no new syntax, noreplacesdeclaration, no shared-name list.ClassNameIsDeliberatelySharedexists only because of this. With real scoping,pylib uses sysutils(or the reverse) gives ONEExceptionby construction and the list is deleted, along with the duplicatedCreateFmtbodies described in that ticket.- NilPy programs inherit Pascal's namespace. A
.npythat reaches sysutils gets its exports unqualified, and Pascal is case-insensitive while Python is not, soformat,date,time,trim,pos,copy,delete,insertand friends all shadow. Measured:print(format(255))in a.npythat imports sysutils reportsformat(AnsiString, record)— a Pascal signature error against Python source — where without sysutils it correctly saysundefined variable (format). Userdefs DO win, so only undefined names are affected, but the failure mode is a silent bind to Pascal semantics instead of a NameError. - 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:
-
Edge table. No per-unit uses graph exists today;
ParseUsesUnitBodyrecords 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 (theimplementationkeyword is matched by string compare at several sites, so the state is genuinely absent, not merely unnamed). -
VisibilityAllows(curUnit, declUnit). True whendeclUnit = curUnit, ordeclUnitis reachable fromcurUnitby 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. -
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 fromFindUClass. -
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.
UsesEdgeFrom/UsesEdgeTo/UsesEdgeIface(compiler/defs.inc) + oneRecordUsesEdgecall inParseUsesUnitBody(compiler/parser.inc), placed BEFORE the already-compiled guard so every clause is counted, not just first-loads.VisibilityAllows(curUnit, declUnit)(compiler/symtab.inc): direct edges (either section) fromcurUnit, then BFS closure over INTERFACE-only edges.--warn-uses-leak(compiler/compiler.pas) gatesWarnUsesLeakHitcalls wired intoFindProc's two flat lookup loops andFindUClass's flat fallback loop (compiler/symtab.inc). Opt-in, read-only: resolution is unchanged, self-host fixedpoint reached at generation 1 (this is NOT an ELF-layout-style change),make test-equivalent smoke (the ticket's own routine repro + a handful oftest/*.pas) compiles clean with the flag off and warns-but-still-compiles with it on.
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):
- 81 distinct (importer, provider) pairs from 80 files alone.
- Top offenders by hit count — confirms "RTL is the biggest consumer":
pylib -> builtinheap(6894),sysutils -> builtinheap(4522),pylib -> builtin(4145),http -> builtinheap(3808),pylib -> <program>(3276, class lookups),ecdsa_p256 -> builtinheap(2980),bignum -> builtinheap(2548),pylib -> sysutils(1717),zlib -> builtinheap(1596) — and dozens more in the hundreds. - Every RTL/pylib unit reaches
builtinheap/builtinwithout declaring it — those two are the ambient intrinsic surface every unit implicitly gets today, so on a real non-transitive rule EVERY unit inlib/rtlwould need an explicituses builtin[heap]added. That is likely mechanical (decide-class-namespace-scopingalready names this shape) but it is hundreds of files, not one.
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-07-31 — resolved, commit d86bc20ec.
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
builtin/builtinheap— pxx's System unit, injected byParseProgram(ParseUsesUnit('builtinheap')/('builtin'),compiler/parser.inc), never written by a user clause, so the edge table structurally could not show them reachable. Fixed:UnitIsAmbientincompiler/symtab.inc, consulted byVisibilityAllows.TObject/TGuid— found by classifying the leftover-> <program>bucket rather than assuming it was real. It was 100% these two names and nothing else (TObject3601 hits,TGuid1092). They are minted byRegisterBuiltinTObject/RegisterBuiltinTGuid, soAddUClassstamps them with whateverCurrentUnitIdxis at ParseProgram time (-1, "the program") — making every unit that namesTObjectlook 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):
- 35 distinct (importer, provider) pairs, 4721 hits.
-> <program>bucket is now 0. - Previous headline was 81 pairs from 80 files. So the real figure is less than half, measured over twelve times as much code.
- 113 of the 934 files don't compile standalone — they are helper
*_unit.pasunits and{%FAIL}cases, not measurement failures.
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.