← board

The zero-argument form of a builtin type constructor is rejected

Measured

print(repr(X)) for each, one file per row:

form pxx CPython
str() error: expected expression ''
int() error: expected expression 0
float() error: expected expression 0.0
bool() error: expected expression False
list() error: no overload of list matches these arguments []
tuple() error: no overload of tuple matches these arguments ()
bytes() error: no overload of bytes matches these arguments b''
dict() OK {}
set() OK set()
frozenset() OK frozenset()
bytearray() OK bytearray(b'')

Why it is one bug and not seven

One concept — "the zero-argument form of a type constructor is that type's zero value" — implemented for four of eleven names. The two error messages are the tell that it is falling through two mechanisms, and each already has a working precedent among the four that pass:

So the fix follows each mechanism's own precedent rather than inventing a third: a zero-value arm for the four intrinsics, and the missing zero-argument overload for the three pylib constructors.

Where it bites

x = x or str() and d.get(k, list()) are ordinary spellings of a default, and int() / float() are how generated and generic code names a numeric zero without committing to a literal. Nothing in the corpus is blocked on it that we have measured — it is filed because it is a plain upward-compatibility break on working CPython code and because the concept is already four-elevenths built.

Gate

Each row above matches CPython. Touches compiler/builtin/**, so it carries the stabilize-fast + make pin obligation.


Resolution (2026-08-27)

All eleven now match CPython. Witness test/test_nilpy_zero_argument_builtin_constructors.npy, registered in test-core as test_nilpy_zeroctor26, .expected generated by CPython. Red at pinned v382 (b12e5e27a), green at HEAD.

Each half fixed by its own precedent, not by a third mechanism

Two mechanisms because there genuinely are two kinds of constructor, and each already had a working example among the four that passed. Growing a third dispatch to cover both would have been the second path that stays broken.

CORRECTION 2026-08-27, same day — the pylib half was REVERTED

The paragraph above reasons from a precedent that was itself a latent bug, and following it broke two gated tests inside an hour ([[regression-test-core-test-nilpy-unbound-builtin-method]], [[regression-test-core-test-nilpy-builtin-subclass-dunder-dispatch]]).

This dialect lets a parameterless function be called by its BARE NAME. So a zero-argument overload does not add the spelling list(); it makes the bare word list a complete call, and list.append(self, x) — how a subclass reaches the base method it just overrode — became list().append(self, x).

All seven now answer in the PARSER, keyed on the name ( ) shape, which by construction cannot capture a bare name or a name before a .. The containers build their zero value from PyParseListLiteralT on the same ( ), so no new pylib name exists whose bare word could be called; list() and bytes() then undo that parser's paren-implies-TUPLE stamp with pylist_mark_list.

So there is ONE mechanism after all, and the "two kinds of constructor" framing was wrong — not about the code, about which property mattered. What separates the two routes is not intrinsic-vs-proc, it is what else the name becomes callable from.

bytearray keeps its overload and stays broken for the unbound spelling — measured identical at v383, original rather than a regression, and filed as [[bug-n-bytearrays-zero-argument-overload-makes-the-bare-name-a-call]]. It is not taken over here because its overload also stamps FIsByteArray, which the parser arm cannot reproduce without a new pylib entry point.

The witness now carries a Stack(list) calling list.append and list.__getitem__ unbound, so the shape is gated from this side too.

The bug inside the fix, caught by varying the shape

The first spelling typed the new str() node as tyAnsiString — the type the call str(x) is inferred as. Measured:

pxx CPython
str() == "" in expression position False True
x = str(); x == "" True True

== dispatches on the static type, so a zero-length literal typed as a managed handle took a different arm from the literal it was being compared with. The value IS a literal and has to be typed like one (tyString), after which str() is indistinguishable from "" in every position measured. The s = s + ("x" * 400) row is there because tyString is also the FROZEN 255-char type and the obvious worry is a ceiling; there is none — the local widens exactly as it does from a plain "".

Also measured and NOT a regression: def d(a=str()) produces an 8.4 MB BSS. def d(a="") produces the identical 8.4 MB at v382. That is the pre-existing cost of the hidden global a string default gets (pyparser.inc, the tkString default arm), and str() simply inherits it — which is the correct outcome for this ticket.

Deliberately NOT in the witness

Two rows in the probe still differ from CPython, both pre-existing and neither this ticket's:

Gate

make compiler/pascal26 + tools/gate.sh quick GREEN. Touches compiler/builtin/pylib.pas, so it needs stabilize-fast + make pin before Track B can use the new spellings.

Log