Repository navigation
v0.10.0 — no more legacy origin fields, three new gates
0.10.0 — 2026-08-20
Three gates, one code-generator feature, and the removal of
@__allow_legacy_any_origin_fields from the entire tree — 243 fields to zero.
The through-line is the same one 0.9.0 kept running into: this codebase's
characteristic failure is something that compiles, passes, and was never
actually executed.
Source-API note for downstream addons. NapiEnv, NapiValue and the eight
other handle aliases are now OpaquePointer[MutUntrackedOrigin] rather than
MutAnyOrigin. Code that only passes handles around — the overwhelming
majority — is unaffected, because every framework signature that takes a handle
takes the alias. Code that spells OpaquePointer[MutAnyOrigin] literally where
a handle is expected will need .as_unsafe_any_origin() at the boundary, or
better, the alias. ModuleBuilder/ClassBuilder constructors deliberately kept
their data: OpaquePointer[MutAnyOrigin] parameter so existing
register_module boilerplate compiles untouched.
Added
-
@__allow_legacy_any_origin_fieldsis gone — 243 fields to zero. Upstream
documents the decorator as an unstable escape hatch slated for removal, and
UnsafeAnyOriginitself as slated for deprecation, so this was never
optional — only unscheduled. The alternative was doing it against a broken
build on a deadline, in the one area of this codebase that has already
produced SIGSEGVs.What made it tractable was finding that the warning in
CLAUDE.mdabout a
naiveMutAnyOrigin→MutUntrackedOriginrename is about a different
population than the decorator's. The 243 decorated fields all point at a
V8-owned handle, a static code address, or anunsafe_allocblock — checkable
rather than a judgement call, since
grep -rn "self\.[a-z_]* = Pointer(to=" src/ examples/is empty, so no field
anywhere holds a pointer to a Mojo local. The 159 dangerous sites are inline
Pointer(to=<local>)…as_unsafe_any_origin()arguments, where the widening
is load-bearing: it keeps a register-passable local's spill slot alive across
the FFI call. The two are disjoint, so the fields could move without touching
the arguments at all.The recipe, now recorded in
docs/plan-origin-migration.md: storage-type the
field; leave every parameter and return type alone; narrow at the write
into the field and widen at the read out of it; keep the
comptime *Fn = def(...) thin abi("C")types atMutAnyOrigin. That last
clause is the rule that must not be broken — "simplifying" it is exactly the
global rename that severs the load-bearing population, it looks tidier, and
nothing in the type system stops you.It is proven rather than asserted.
spike/ffi_probe.mojocarries the recipe
decorator-free and CI builds and runs it on both platforms:originProbe
creates a JS string through a local output slot, reads it back through a
second, and byte-compares — four payloads of different lengths, so a slot
clobbered by its neighbour fails on content where a null check would pass on
garbage. A compile-time assert pins that the origin parameter does not change
the machine representation and thatNapiPropertyDescriptoris still 64
bytes; if it ever did, everynapi_define_propertiescall would corrupt
silently.Landed in five steps, each independently green: the spike and plan, then
NapiBindings' 143 slots, thenNapiPropertyDescriptorand the registry
internals, then a verified no-op commit pinningraw.mojo's 143 FFI type
expressions to a literalMutAnyOrigin(they were spelled with the aliases,
which would otherwise have moved underneath them), and finally the alias flip
itself. The generator no longer emits the decorator either, so
npm run generate:addon && git diff --exit-code src/generated/holds. -
Docstring coverage ratchet.
scripts/check-docstring-coverage.mjsruns
mojo doc --diagnose-missing-doc-stringsover the consumer-facing modules and
compares per-file counts againstscripts/docstring-floor.json— 351
undocumented public symbols across 38 modules at adoption. Per-file rather
than one total, because a single number lets documenting one module pay for
regressing another. The ratchet fails in both directions: rising is a
regression, and falling without updating the floor would let the coverage
just added be undone silently later.Asking the compiler rather than a regex matters for more than tidiness —
mojo docelaborates every declaration in the file it opens, so the gate is
a third view onto lazily-checked code afterframework_coverage.mojo(method
bodies) andtests/codegen/(generator templates). A doc failure there is a
finding, not gate noise. Three framework files thatmojo doccannot open at
all — the main-module__moveinit__compiler bug — are listed in
KNOWN_UNDOCUMENTABLEwith their reason, and the script fails if one ever
starts working, so the skip cannot outlive the bug."""is now the documented convention for public framework symbols, with a
worked example inCONTRIBUTING.md. No sweep of the existing##blocks. -
Per-call overhead gate. A non-required
benchmarkjob compares median
ns/call against per-platform ceilings inscripts/benchmark-ceilings.json.
CachedNapiBindingsexists to keep adlopen(NULL)+dlsymoff the hot
path and nothing had ever checked it stayed off; a reintroduced per-call
lookup costs 10-100x and would ship green.Calibrated for that and honest about the rest: it will not see a 10%
regression on a shared runner and does not claim to.macos-latestmeasures
~2.7x the per-call cost ofubuntu-latest, hence per-platform ceilings and a
hard error on an unrecorded platform. Every run prints observed/ceiling as a
percentage and emits a CI warning above 75%. -
mojo_fnon class setters and static methods, the last two generator
rejections. A setter takes the tagged unwrap plus a declared
type = "<token>"for its value and mutates throughmut; a static takes no
state at all and works on a class with nostatedeclared. -
Runtime coverage for native class state.
state = "<struct>"shipped in
0.9.0 with codegen coverage only — the kitchen sink compiles a state-backed
class but never loads it, andsrc/exports.tomldeclared none — so
wrap/unwrap, the type tag,mutmutation across calls and the finalizer had
never run. TheTallyclass andtests/class_state.test.jsdrive all of it,
including borrowing a method, getter or setter onto a foreign wrapped
instance, which must be aTypeErrorrather than a reinterpret.
Fixed
raw_create_promisepassed itsdeferredargument as if it were a handle.
It is an output slot — anapi_deferred*the callee writes — and its caller
hands itPointer(to=<local>)…as_unsafe_any_origin(). Retyping it surfaced
the mistake at compile time; under the old aliases the same confusion would
have compiled silently, which is the general argument for the flip.NapiNodeVersioncould not compile:releasewas missing
@__allow_legacy_any_origin_fields, latent since dev2026062206 because
nothing constructs the struct and struct definitions elaborate lazily. Found
by the docstring gate's first run.toml-dts.jsignored?throughout the class block, so a class method or
getter declared"number?"was typednumberwhile the generated callback
has a realJsNullbranch. Class members now use the same nullable-aware
mappers as top-level functions, for arguments and returns alike. A
setter-only property with a declared type emits that type instead ofany.- A generated setter did not type-check its value: the check lives in
emitArgPreamble, which the setter path does not use, so a wrong type fell
through to theexcept:block as " setter failed" rather than
"expected number, got string".
Changed
- Two test assertions that rotted rather than guarded now derive from the
source:typescript.test.jsbuilds its class skip-list from the.d.ts's
ownexport classlines (and checks every declared class is really on the
addon, so the derived list cannot hide a missing export), and
toml_lite.test.jsderives the expectedextra_importscount instead of
hardcoding it. scripts/benchmark.mjsgains--jsonand$NAPI_MOJO_BENCH_BATCHES; the
human-readable table is unchanged.CLAUDE.mdreferred to@qkstat/ragandpackages/rag, which do not
exist. The downstream packages are@qkstat/retrieve(which holds the GPU
work) and@qkstat/embed.