← board

A default argument is dropped on every cross-module call

Repro

plainmod.py:

def withdef(a, lo=7):
    return lo

def twodef(a, lo=7, hi=9):
    return lo + hi

class C:
    def m(self, a, lo=7):
        return lo
import plainmod
print(plainmod.withdef(1))     # prints None    -- CPython prints 7
print(plainmod.twodef(1))      # prints 0       -- CPython prints 16

Exit 0. No diagnostic. No crash. The value is simply wrong.

The boundary, one variable at a time

call shape result correct
same-file f(1) with lo=7 7
same-file method C().m(1) 7
from M import f then f(1) 7
import M then M.f(1) None
import M as m then m.f(1) None
from M import f as g then g(1) segfault
from M import C then C().m(1) None
two defaults omitted, cross-module 0 (or segfault)
any of the above with the argument supplied explicitly correct
imported function with no defaulted parameters correct

So the discriminator is not the alias and not the qualification — it is crossing a module boundary while letting a default apply. The defaults appear not to travel with the imported symbol, so the call site passes fewer arguments than the body reads and the missing ones arrive as None / 0 / whatever was there.

That paragraph is a reading of the table, not a measurement of the lowering.

Relationship to the alias ticket

[[bug-n-calling-through-a-function-alias-with-a-default-omitted-segfaults]] (p70) is a symptom of this, not a separate fault: the alias cases are the sub-rows above where the garbage happens to get dereferenced. That ticket's own boundary work already found the silent-wrong variant at one default. This ticket is the general statement; fixing this should close that one, and the alias ticket's repro is worth keeping as a regression test because the crashing shape is the one that fails loudly.

Why p90 and urgent

What to check when fixing

Verify by value, cross-module, for: a function default, a method default, several defaults where only some are omitted, a default that is a string or a tuple rather than an int (None-shaped garbage may read as a plausible empty value), and a keyword argument passed by name out of order. The single-file control passing is not evidence of anything here.


Coordinator verification 2026-08-18 — confirmed against the CPython oracle

Reproduced independently at HEAD, differential against CPython on the same source file (the .npy is run by python3 unmodified), module named lowercase to avoid the unrelated unit-name-case confound:

# mmod.py
def f(a, lo=7):        return lo
def g(a, lo=3, hi=13): return lo + hi
class C:
    def m(self, a, lo=7): return lo
call pxx CPython
from mmod import f; f(1) 7 7 ok
import mmod; mmod.f(1) None 7 DIVERGES
import mmod as m; m.f(1) None 7 DIVERGES
from mmod import C; C().m(1) None 7 DIVERGES
import mmod; mmod.g(1) (two defaults) 0 16 DIVERGES
import mmod; mmod.f(1, 3) 3 3 ok

Exit 0 throughout, no diagnostic. Confirms the filed boundary exactly: the defect is crossing a module boundary while letting a default apply, and from X import f is the one form that survives.

The suite-blindness claim, measured

The ticket argues the .npy suite cannot see this because single-file programs are all correct. Measured statically, and it holds:

716   .npy tests in test/
 10   sibling .py modules in test/

So at most ~10 of 716 tests can exercise a call into a user module at all — the 80 files using bare import X are overwhelmingly importing stdlib names and shims, not local siblings. Coverage of this shape is close to nil, which is consistent with a defect this broad surviving unnoticed.

Consequence for the corpus numbers, and this is the part to carry

Every "N/48 compiles" figure this campaign has published — including today's 6/48 — is a claim about compiling, not about running. The corpora are multi-module by construction, so the shape this bug breaks is the shape they are made of. No ladder number should be read as "the library works" until this lands. That is not a caveat on one report; it applies retroactively to every ladder A/B in [[feature-nilpy-thirdparty-libraries-as-targets]].

The alias-default ticket (bug-n-calling-through-a-function-alias-with-a-default-omitted-segfaults, p70) is a SYMPTOM of this one — the alias rows are where the dropped default happens to get dereferenced instead of silently substituted. Keep its repro as a regression test, since a crash is the shape that fails loudly, but fix it here.

FIXED 2026-08-18 (frank2-7e, combined A+N)

Root cause — an over-broad predicate, measured before it was touched

DefaultArgValueNode (compiler/parser.inc, ~2988):

else if isNilPy and (Procs[mpi].Params[k].TypeKind = tyVariant) and
        (ProcParamDefaultIsNone[mpi * MAX_PROC_PARAMS + k] or
         (ProcUnitIdx[mpi] >= 0)) then
  exprNode := PyMakeNone

ProcUnitIdx[mpi] >= 0 means "this routine lives in another unit", and it forced the None path regardless of the declared default.

Confirmed with a probe before editing, rather than read off the source — the declared value is present and correct and is simply discarded:

PXXDBG n.defarg proc=f k=1 unitidx=612 tk=22 isnone=FALSE sym=-1 val=7 isNilPy=TRUE
                          ^^^^^^^^^^^^ forces None            ^^^^^ the right answer

The clause is deliberate and load-bearing, not an oversight. Its comment says why: a lib/pcl Pascal façade declares const opt: Variant = 0 as a SENTINEL meaning "not supplied", and 26 call sites across lib/pcl test pyvartag(v) <> 0 to find out. Boxing that 0 as an integer made an omitted option look GIVEN — canvas.configure(yscrollcommand=...) passed a filled xscrollcommand and tkinter refused it.

So ProcUnitIdx >= 0 was standing in for "this is a Pascal library façade", and a user's imported .py module satisfies the proxy while needing the opposite answer. A fact inferred from a proxy instead of recorded.

Fix — record the fact

A per-unit "this unit is a NilPy module" marker on the existing CTUnitIdx / UnitIsCTranslationUnit pattern: PyModUnitIdx in defs.inc, UnitIsPyModule / MarkUnitPyModule in symtab.inc, set in ParsePyUnit. The predicate then reads (ProcUnitIdx[mpi] >= 0) and not UnitIsPyModule(...).

A parallel array, not a TProc field — a new field there is the known self-host landmine (project_tsymbol_field_landmine), and the C-translation-unit list next to it already uses this shape.

Verified by VALUE against CPython, every shape the ticket asked for

call before after CPython
import M; M.f(1) None 7 7
import M as m; m.f(1) None 7 7
import M; M.g(1) (two defaults) 0 16 16
import M; M.g(1, 5) (only some omitted) 5 18 18
from M import C; C().m(1) None 7 7
M.s(1) (string default) dflt dflt dflt
M.f(1, 3) (supplied) 3 3 3

The string default was already correct, which is diagnostic rather than incidental: ProcParamDefaultIsStr is tested before the variant branch, so only variant-typed parameters were ever affected.

The façade behaviour is NOT regressed

The risk in this fix was trading a silent NilPy bug for a silent tkinter one. The three tk examples exercise configure() with omitted options and now actually RUN under Xvfb (5215148bb): tkinter_facade, field_class_identity and callbacks all still match their .expected byte for byte.

Regression test

test/test_nilpy_cross_module_defaults.npy + test/nilpy_units/defmod.npy, wired into test-nilpy by name (the suite enumerates and never globs). Verified both ways: on pinned v348 it prints None / 0 / 5 / 3 / dflt / None, at HEAD 7 / 16 / 18 / 3 / dflt / 7.

Why the suite could not see this, now covered: it needs a .py callee, a variant parameter and an omitted argument at once. A Pascal-callee test would have passed — import pasmod; pasmod.pf(1) with lo: Integer = 7 is correct today and always was, because an Integer parameter never reaches the branch.

Two OTHER bugs found here — this ticket does NOT retire the alias one

  1. [[bug-n-an-import-alias-binds-to-a-same-named-member-of-the-source-module]] (filed, N p85). from mmod import f as g binds g to mmod's own g: g(1, 5) gives 18 with every argument supplied, so no default is involved. Aliasing to a name that is a CLASS in the module constructs that class instead. Independent of this ticket and unaffected by this fix.

  2. Calling through a function-VALUED name still drops defaults — and its scope is wider than [[bug-n-calling-through-a-function-alias-with-a-default-omitted-segfaults]] states. from M import f as zz; zz(1) is still wrong after this fix, and so is a plain same-file zz = loc; zz(1). So it is not about imports or module boundaries at all: a call through a procedural value does not consult the callee's defaults. That ticket stays open and should be re-scoped.

Gate

make compiler/pascal26 (fixedpoint, converged) + the value table above + the tk façade check + the new test failing pre-fix and passing post-fix + tools/gate.sh quick GREEN (FPC seed canary included — this adds a routine and a parallel array). No pin needed; nothing in compiler/builtin/**.

Log