← board

a NilPy import leaks the unit's names into the Python namespace

Split out of [[bug-nilpy-uses-math-breaks-abs-on-a-float]] on 2026-08-16, which fixed the abs symptom at its intercept and left the cause standing.

The rule being broken

In Python, import math binds exactly one name: math. It does not put floor, sqrt, pow — or anything else — into the importing module's namespace; that is what from math import * is for, and nobody writes it. In NilPy the import is implemented as ParseUsesUnit(impName) (pyparser.inc, the plain-import arm), which is Pascal's uses: every routine the unit declares becomes visible unqualified and joins the overload set for its name.

So a Python builtin now competes with whatever the RTL unit happens to declare, and which one wins is decided by overload ranking — i.e. by argument widths and declaration order in a file the user never opened.

Measured

import math
print(max(1.5, 2), min(2, 1.5))
CPython pxx
max(1.5, 2) 2 2.0
without import math 2 2

CPython's max returns the ARGUMENT, preserving its type; math's Max(Double, Double) returns a Double, so the int is silently widened. The value is not wrong, the TYPE is — which is the kind that survives into a isinstance/%d/dict-key further down.

abs was the loud member of the same family (abs([-0.0][0]) printed a pointer) and is fixed; min, max, round, floor, ceil, sqrt, exp, ln and power are all declared by lib/rtl/math.pas and all have NilPy builtins of the same name.

Fix shape

Load the unit, do not publish its names. The visibility machinery landed 2026-08-15 (DeclVisible / VisibilityAllows / non-transitive uses) is the place: a unit pulled by a NilPy import wants a QUALIFIED-ONLY mode, so math.floor(...) resolves and bare floor does not see it. Everything NilPy serves through a module qualifier keeps working; everything the Python builtin surface owns stops being ambushed.

Do NOT do it per name at the intercepts. The abs fix did that on purpose, because it was one call site and the value was actively wrong, and the write-up says so — but there are ten names here and each intercept is a separate mechanism. One qualified-only flag replaces all of them.

Check the fix against the whole lib/rtl/math.pas export list, not just max: that is the ticket this one was split out of, making the same request one level up.

Test

test/test_nilpy_abs_under_import_math.npy already covers the abs half. The min/max rows belong beside it once this lands.

FIXED 2026-08-16 — load the unit, do not publish its names

A plain import X sets PyImportPending; the unit resolver's Pascal branch alone marks the resolved unit qualified-only (MarkUnitQualifiedOnly). A .py module import, a C-header import and every Pascal uses never reach that line with the flag set, so nothing outside NilPy's plain-import path changes.

DeclVisibleBareRoutine is DeclVisible plus "…and not a qualified-only unit, while NilPy USER code is being compiled". It is consulted at FindProc's two chain loops, the builtin-demotion scan, and MatchEligBase.

Two things this cost, both worth writing down

It is ROUTINES only, and that was not the first design. The rule started inside DeclVisible itself, which hid the unit's classes and types too — and class D(mixinproto.Proto) promptly stopped working: the base is REACHED through the qualifier, but once it is a base its members are resolved by the compiler, not by a name the user wrote, and hiding them broke inherited-method lookup outright (Before has no method Twice). Types, constants and symbols are the same case. Only a BARE ROUTINE NAME is what Python's import rule actually governs, so the predicate moved out of DeclVisible and into the routine lookups.

FindProc is not where a call resolves. With only the FindProc loops patched, a bare Power was correctly hidden and max(1.5, 2) still answered 2.0 — because a direct call site resolves through MatchEligBase, which is exactly the blind spot --warn-uses-leak's own comment three lines above it records having had. Both had to be patched.

QualNameLookup turns the rule off for the frontend's own by-name lookups, so PyParseStdlibCall still resolves math.pow to Power and math.sqrt to Sqrt — naming the module is precisely what the import binds.

Measured, all against CPython

import math
min(1.5, 2), max(1.5, 2), max(2, 1.5), min(2, 1.5)   1.5 2 2 1.5     was: 1.5 2.0 2.0 1.5
min(-0.0, 0.0), max(-0.0, 0.0)                       -0.0 -0.0       was: 0.0 0.0
abs(-1.5), abs(-0.0), abs(-3), round(2.5)            1.5 0.0 3 2
math.floor / ceil / sqrt / fabs / trunc / copysign / log / log10 / pi   all unchanged
Power(2.0, 3.0)                                      undefined variable (Power)   <- correct

The one row still divergent is math.pow(2.0, 0.5), and it is not this: it is [[bug-b-power-lost-an-ulp-on-a-half-integer-exponent]], filed earlier the same day against Track B's Power rewrite.

Unblocks

[[bug-a-nilpy-star-star-has-its-own-low-precision-pow]] — its prototype is measured (107/120 exact, worst 1 ulp) and was blocked first by abs and then by min/max. Both walls are down. Whoever takes it should re-apply devdocs/dev/prototypes/nilpy-float-pow-via-rtl-power.patch and re-run the oracle rather than assume.

Gate

make compiler/pascal26 (self-host fixedpoint, byte-identical) + tools/gate.sh quick GREEN. Verified by hand beyond the gate: every .npy test that imports a Pascal unit (mixinproto, sysutils, stdlib, tobjprobe, the C-header one) compiles and still answers, the multiple-inheritance test still prints its four exact lines, the two-imported-bases refusal still refuses, and uforth.py still compiles and runs. test/test_nilpy_import_does_not_publish_names.npy pins the whole set against CPython, qualified spellings included.

Log

AMENDED same day — the rule is narrower than this write-up first said

Track T came back red on three examples/tk/*.npy programs within the hour. They write import tk and then call a bare TkInit(); hiding every routine of an imported unit made that a hard error. It should not be one: CPython would raise NameError there, and NilPy accepting what CPython rejects is a feature of this dialect, not a defect — the compatibility promise runs one way (devdocs/dev/nilpy-semantics-divergences.md).

So the final rule drops a qualified-only unit's routine only when the Python side already declares that name (PythonSideDeclaresRoutine: pylib, pyeval, or a compiler-minted proc). That is precisely the ambush set — abs/min/max/round — where the user's own working meaning silently changes. TkInit, Tk_ and Trim collide with nothing and still resolve.

Read the two together: the sections above describe how the mechanism is wired (qualified-only marking, DeclVisibleBareRoutine, the MatchEligBase gate), and this one describes what it is allowed to hide. Both were arrived at by being wrong first, in opposite directions — too narrow a hook, then too wide a rule — which is why they are recorded rather than smoothed over.