Builtin surface gaps found by the 2026-08-12 sweep
- Type: bug / missing surface — Track N
- Found: 2026-08-12, sweeping every builtin against CPython one call at a time (the method [[feedback_sweep_operators_against_oracle_not_just_features]] recommends, applied to builtins).
- Companion to [[feature-nilpy-small-syntax-gaps-found-by-the-2026-08-06-sweep]].
1. key=None raises — this one is a real defect, not a gap
sorted([3, 1], key=None) # CPython: [1, 3]
pxx raises:
TypeError: parameter key is not callable — the value is None (an import that did not resolve, or a name never assigned)
CPython defines key=None as "no key function" — it is the documented
default, so passing it explicitly is legal and common when an optional key is
threaded through a helper (def show(xs, key=None): return sorted(xs, key=key)).
Same for min/max. The diagnostic is good and should stay for a genuinely
unresolved name; it just must not fire for the value None on key=.
2. zip(a, b, c) does not parse
list(zip([1], [2], [3])) # CPython: [(1, 2, 3)]
pxx: error: unexpected token. Two arguments work. Three-way zip is ordinary
(rows/labels/values), and the failure is at parse time.
3. A computed precision in a format spec does not parse
n = 3
print(f"{7.5:.{n}f}") # CPython: 7.500
pxx: ValueError: unsupported format spec ".{n" — the nested replacement field
inside the spec is taken literally. Every other spec form checked works
(width, alignment, fill, e/g/x/o/b, ,, +, !r, {{literal}},
subscripts and expressions inside the field), so this is the one hole in an
otherwise complete f-string surface. A computed precision is how a report makes
its decimal places configurable.
4. Absent builtins
Each is error: undefined variable, in rough order of how often real code
reaches for them:
| builtin | note |
|---|---|
iter() / next(it) |
next(gen) is how a generator is driven by hand |
callable(f) |
the standard "is this a function" guard |
issubclass(A, B) |
isinstance exists; its class-level twin does not |
type(x) == int |
type(x) is supported ONLY as type(x).__name__ (its own diagnostic says so) |
format(v, spec) |
the function behind f-strings; the f-string itself works |
frozenset(...) |
set is complete otherwise |
max(xs, default=0) |
the empty-sequence guard |
id(x) |
identity |
complex(1, 2) |
no complex type at all |
slice(1, 2) |
slicing syntax works; the object does not |
ascii(x), dir(x), vars(), memoryview(b) |
rarer |
eval(s) |
deliberately absent? if so it belongs in the divergences page, not here |
Everything else in the sweep agreed: hash, divmod, pow (2- and 3-arg),
repr, sum with a start, any/all, isinstance with a TUPLE of types,
bytearray, and the whole set surface.
Related but NOT a bug — eager iterators
map, filter, reversed and enumerate return LISTS rather than lazy
iterator objects, so print(map(str, [1])) prints ['1'] where CPython prints
<map object at 0x...>. Every ordinary use (list(map(...)), iterating it,
comprehending over it) agrees. It is a real semantic difference — code that
relies on laziness for an unbounded source, or on the iterator being consumed
once, would notice — so it belongs in devdocs/dev/nilpy-semantics-divergences.md
with that caveat spelled out, rather than in this ticket.
Gate
A .npy diffed against CPython per item as it lands: key=None on
sorted/min/max (plus the existing diagnostic still firing for a genuinely
unassigned name), three- and four-way zip, and one asserting call per builtin
added.
Item 1 DONE 2026-08-13 — sorted(key=None); min/max still open
sorted(xs, key=None) sorts, in every spelling swept: explicit, with
reverse=, over strings, over a dict, over an empty list, and — the shape that
matters — threaded through a helper's own key=None default, both passed on
blindly and branched on with if key is None.
Fixed at the coercion, keyed on the CALLEE'S DEFAULT. A variant argument
heading for a Pointer parameter is coerced by pyvar_callable_ptr, which
raises on None. That is right for a parameter with no default — CPython refuses
map(None, xs) too, and the map row is in the test to keep it that way — and
wrong for sorted(l; key: Pointer = nil), where nil ALREADY means "no key
function", which is exactly what CPython says key=None means. So the call
path now picks a None-tolerant twin when ProcParamHasDefault says the callee
declares one. Keyed on the declared default rather than on the parameter being
called key: the name is not what makes nil meaningful.
Test test/test_nilpy_sorted_key_none.{npy,expected}, wired into test-nilpy.
Still open in item 1: min/max with key=None
min([3, 1], key=None) still raises TypeError: expected a number, got object, and by a different mechanism — nothing to do with the coercion. It
picks the two-argument NUMERIC overload min(const a, const b) and compares
the list against None, the same mis-resolution PyMinMaxByKey already works
around for a callable held in a variable (it detects a callable second argument;
None is not callable, so it falls through). Whoever takes it should decide
between routing an explicit key= keyword at the frontend and widening that
runtime detection — min(x, None) positional is a TypeError in CPython, so the
runtime arm cannot simply treat a None second argument as "no key" without
turning that into a silently wrong answer.
Item 4, first name DONE 2026-08-13 — callable(x)
Answers, matching CPython on every shape swept: a def, a lambda, a bound method,
an instance of a class defining __call__, a plain instance, ints / strs /
floats / None, all four containers, held in a variable, passed as an argument,
and used as a VALUE rather than only as an if condition.
Two halves, and only the first was free. PyVarIsCallable — the definition of
"callable" this unit already commits to for min/max's key detection — had been
in pylib all along and simply was not in the interface, so declaring it under
its Python name is the whole fix for functions and methods; no frontend
intercept. But it answers from the variant TAG, and an instance of a class with
__call__ is an ordinary VT_OBJECT, indistinguishable there from a plain
instance, so that case asks the class RTTI (PyFindDunder(cls, '__call__'))
when the tag says object. The plain-instance row in the test is what keeps that
from degenerating into "any object is callable".
Test test/test_nilpy_callable_builtin.{npy,expected}, wired into test-nilpy.
Noted, not fixed: callable(print) and callable(len) do not PARSE — a
BUILTIN taken as a value, which is its own gap and unrelated to callable
(print as a bare name fails the same way anywhere). Not filed separately here
because [[bug-nilpy-small-builtin-surface-gaps-found-by-the-2026-08-13-sweep]]
already covers builtins-as-values for the str methods; worth folding in there if
it comes up again.
Still absent from item 4: frozenset (own ticket), id, slice, complex, format,
ascii, eval, dir, vars, memoryview, and max(default=).
Item 4, format(v[, spec]) DONE 2026-08-13 — and issubclass was already there
format now answers for every spec the f-strings take (checked: .2f, x,
08b, o, >5/<5/^6, ,, +.3e, the empty spec, a one-argument call, a
spec held in a VARIABLE, threaded through a helper, inside a comprehension, and
from a method body). One implementation with the f-string holes — the test
asserts f"{7.5:.2f}" == format(7.5, ".2f") so they cannot drift.
A FRONTEND intercept, not a pylib function named format, and the difference
is the whole lesson. Declared in pylib it worked — until the program said
import json: sysutils declares Format(fmt, [args]), and a later unit
SHADOWS the whole name rather than joining its overload set, measured with the
pylib arms absent from the candidate list. So the first version compiled every
test in this ticket and would have broken on the first real program. A builtin
that stops existing when you add an import is worse than a missing one. The
import json row is in the test to keep it that way, and the .npy's own def format still wins (ProcUnitIdx = -1 is the main program), which is Python's
rule for shadowing a builtin.
Test test/test_nilpy_format_builtin.{npy,expected}, wired into test-nilpy.
issubclass(A, B) needs nothing — measured today, it compiles and answers
correctly for a subclass, so it landed between the sweep and now. Struck from
the list above rather than re-measured every pass.
Still absent from item 4: id, slice, complex, ascii, eval, dir, vars,
memoryview, max(default=), and type(x) == int. Items 2 (three-way zip)
and 3 (a computed precision in a format spec) are untouched.
Item 2 DONE 2026-08-13 — three- and four-way zip
zip(rows, labels, values) parses and runs, as a value, in a comprehension,
and as a for-header with three names. Four streams is the ceiling; past it the
diagnostic says so rather than dropping one silently.
Two halves, and the second is the interesting one. The cursor grew FUp3 /
FUp4 (nil below three/four streams) and the advance appends a stream only
when its field is set, so a two-way zip still yields a PAIR and not a triple
padded with None — the row asserting that is in the test. Shortest-wins is
unchanged and still consumes the streams to the LEFT of the exhausted one,
which is CPython's observable order.
The for-header needed no desugar of its own. With zip an N-way expression,
three names fall through to PyParseForIn, which already unpacks a 3-tuple
from any iterable — measured first on a plain list of tuples, which is what
said the general path was there. So the fix was to STOP the two-name index-walk
desugar from claiming the header (it keyed on "a second name exists", which is
true of three names too) and delete the refusal. Exactly the shape of the
comprehension fix recorded above it: the special case was only ever the
refusal.
Test test/test_nilpy_zip_n_way.{npy,expected}, wired into test-nilpy; the
nine existing zip-using tests re-run by name and unchanged.
Item 3 DONE 2026-08-13 — a computed precision in a format spec
f"{x:.{n}f}" works, as do a computed WIDTH (f"{'x':>{w}}"), an expression
(.{n + 1}f), a whole conversion character ({255:{'x'}}) and a precision of
zero. The plain-literal spec and the no-spec hole are in the test as controls.
The spec stays a STRING handed to the formatter: the nested hole becomes a CONCATENATION in the rewritten source, so the spec mini-language still has exactly one implementation and the lexer never learns what "05x" means. That is the same reason the spec was captured verbatim in the first place.
Worth recording because it cost two failed builds: the explanatory comment
originally CONTAINED the shape it described, and a Pascal brace comment ends at
the first closing brace in it — so the comment terminated inside itself and the
lexer met a stray quote nine lines later, reporting unexpected character at a
line whose text looked perfectly fine. The comment now spells the shape out in
words.
Test test/test_nilpy_fstring_computed_spec.{npy,expected}, wired into
test-nilpy; the five existing f-string tests re-run and unchanged.
This closes items 2 and 3. Remaining: item 1's min/max with key=None,
and item 4's id, slice, complex, ascii, eval, dir, vars, memoryview,
max(default=) and type(x) == int.
Item 1 CLOSED 2026-08-13 — min/max with key=None
All three spellings work now: the literal, a variable holding None, and the
helper-default case (def show(xs, key=None): return min(xs, key=key)), over a
list, a string and a range. Controls in the test: a real key function, the plain
one-argument forms, and the two-argument numeric form.
The ticket's own note asked whoever took it to choose between routing key= at
the frontend and widening the runtime detection, warning that the runtime arm
"cannot simply treat a None second argument as no-key without turning that into
a silently wrong answer". It can, with one guard, and the warning does not
survive the dialect's own rule: min(xs, None) positionally is a TypeError in
CPython, so no program CPython ACCEPTS can observe the difference — the same
argument PyMinMaxByKey already makes one function above for a callable second
argument. Guarded on a SEQUENCE first argument, so min(3, None) keeps raising:
that one really is a comparison someone wrote by mistake.
Noted, pre-existing, not touched: min(3, None) answers None where
CPython raises TypeError (the pinned compiler agrees, so it predates this).
Filed as [[bug-nilpy-comparing-none-with-a-number-answers-instead-of-raising]].
Still open in item 1's neighbourhood: max(xs, default=0). It needs an
overload carrying a default parameter, and every shape of that collides with
the existing key= arity — max(l, key: Pointer = nil) and
max(l, const default: Variant, key: Pointer = nil) are ambiguous for a
one-argument call, and Pascal will not take a non-defaulted parameter after a
defaulted one. It wants the keyword to be resolved against the overload SET
rather than the chosen member (bug-nilpy-keyword-arg-vs-overload-set), so it is
filed there rather than bodged here.
Test test/test_nilpy_min_max_key_none.{npy,expected}, wired into test-nilpy.
RE-MEASURED 2026-08-14 — most of this ticket is now stale. What is left is six names.
Re-ran every open row against the compiler at HEAD rather than reading the list, because five of the sessions above each closed part of it and the remaining-items lines were written before the last two landed.
| row | status at 2026-08-14 |
|---|---|
1. sorted(key=None) |
done (2026-08-13) |
1b. min/max with key=None |
DONE — measured, works in every spelling; the "still open" note below is stale |
2. three-way zip |
done (2026-08-13) |
3. computed precision f"{7.5:.{n}f}" |
DONE — answers 7.500, matching CPython. Landed since, unrecorded here |
callable |
done (2026-08-13) |
issubclass |
done |
format |
done (2026-08-13) |
frozenset |
DONE — frozenset([1,2]) compiles and runs |
iter / next |
DONE — and this was the html5lib ladder's cheapest wall, 5 files |
type(x) == int |
DONE 2026-08-14 — bug-n-a-type-name-is-not-a-first-class-value |
id(x) |
DONE 2026-08-14, here |
ascii(x) |
DONE 2026-08-14, here |
max(xs, default=0) |
DONE 2026-08-14, here |
slice(1, 3) |
still open — undefined variable (slice) |
complex(1, 2) |
still open — no complex type at all |
dir(x) / vars() / memoryview(b) |
still open |
eval(s) |
still open, and still the question the ticket asks: deliberate or not? |
So the honest remaining list is six names — max(default=), slice,
complex, dir, vars, memoryview — plus min/max with key=None and the
eval question. Everything else this ticket opened is closed.
callable(print) is a different gap and it is now HALF closed
The 2026-08-13 note recorded that callable(print) and callable(len) do not
parse — a BUILTIN taken as a value — and guessed it belonged with the str-method
work. Partly right: builtin TYPES (str, int, list, ...) are values now, so
callable(str) parses. Builtin FUNCTIONS (print, len) still do not: they are
not types and have no value representation. Worth its own item if it recurs.
eval is a Track U question, not a gap
The ticket asks "deliberately absent? if so it belongs in the divergences page". Nobody has answered it in three sessions, and it is a design call rather than work — a compiled dialect either carries a parser at run time or it does not. Filed as [[decide-nilpy-eval-at-runtime]] rather than left as a table row that reads like a to-do.
2026-08-14 — id, ascii, max(default=) landed; FOUR names left
Closing out the re-measurement above.
id(x)andascii(x)— frontend intercepts, following this ticket's ownformatlesson (a pylib routine of that name vanishes the moment a laterusesunit shadows it; the test imports json to keep that honest).max(xs, default=D)/min(xs, default=D)— a lowering, becausedefault=names no parameter and the binder is right to refuse it. TWO dedicated routines rather than another overload: a second Variant parameter is the slotmax(a, b)already claims, and that exact collision is what this ticket's own item 1 spent two sessions working around forkey=.min/maxwithkey=Nonewas already fixed. The "Still open in item 1" section above is stale — measured today in every spelling, including threaded through a helper'skey=Nonedefault. Left in place rather than deleted, since it records why the two mechanisms differ.
Remaining, and honestly: four names and one question
slice, complex, dir, vars, memoryview — plus
[[decide-nilpy-eval-at-runtime]], which is now a Track U ticket rather than a
row here.
None of them has appeared in the html5lib ladder or any corpus scan, and
complex is not a name — it is a numeric TYPE this dialect does not have, which
makes it a feature rather than a missing builtin and probably its own ticket if
anyone wants it. This ticket should be closed and the residue re-filed
rather than kept open as a five-row list nobody is reaching for; it has served
its purpose, which was to turn one sweep into eleven landed builtins.
Found while here, filed separately
[[bug-nilpy-max-and-min-do-not-iterate-a-dict]] — max(d) raises where CPython
answers the largest KEY, while for k in d / sorted(d) / list(d) all agree
with CPython. An inconsistency inside NilPy rather than a deliberate divergence.
Log
- 2026-08-14 — resolved, commit 09bf3b648.