Skip to content

v0.10.0 — no more legacy origin fields, three new gates

Choose a tag to compare

@codetalcott codetalcott released this 20 Aug 19:17
· 140 commits to main since this release
755900e

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_fields is gone — 243 fields to zero. Upstream
    documents the decorator as an unstable escape hatch slated for removal, and
    UnsafeAnyOrigin itself 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.md about a
    naive MutAnyOrigin → MutUntrackedOrigin rename 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 an unsafe_alloc block — 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 at MutAnyOrigin. 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.mojo carries 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 that NapiPropertyDescriptor is still 64
    bytes; if it ever did, every napi_define_properties call would corrupt
    silently.

    Landed in five steps, each independently green: the spike and plan, then
    NapiBindings' 143 slots, then NapiPropertyDescriptor and the registry
    internals, then a verified no-op commit pinning raw.mojo's 143 FFI type
    expressions to a literal MutAnyOrigin (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.mjs runs
    mojo doc --diagnose-missing-doc-strings over the consumer-facing modules and
    compares per-file counts against scripts/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 doc elaborates every declaration in the file it opens, so the gate is
    a third view onto lazily-checked code after framework_coverage.mojo (method
    bodies) and tests/codegen/ (generator templates). A doc failure there is a
    finding, not gate noise. Three framework files that mojo doc cannot open at
    all — the main-module __moveinit__ compiler bug — are listed in
    KNOWN_UNDOCUMENTABLE with 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 in CONTRIBUTING.md. No sweep of the existing ## blocks.

  • Per-call overhead gate. A non-required benchmark job compares median
    ns/call against per-platform ceilings in scripts/benchmark-ceilings.json.
    Cached NapiBindings exists to keep a dlopen(NULL) + dlsym off 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-latest measures
    ~2.7x the per-call cost of ubuntu-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_fn on 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 through mut; a static takes no
    state at all and works on a class with no state declared.

  • 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, and src/exports.toml declared none — so
    wrap/unwrap, the type tag, mut mutation across calls and the finalizer had
    never run. The Tally class and tests/class_state.test.js drive all of it,
    including borrowing a method, getter or setter onto a foreign wrapped
    instance, which must be a TypeError rather than a reinterpret.

Fixed

  • raw_create_promise passed its deferred argument as if it were a handle.
    It is an output slot — a napi_deferred* the callee writes — and its caller
    hands it Pointer(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.
  • NapiNodeVersion could not compile: release was 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.js ignored ? throughout the class block, so a class method or
    getter declared "number?" was typed number while the generated callback
    has a real JsNull branch. 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 of any.
  • 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 the except: 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.js builds its class skip-list from the .d.ts's
    own export class lines (and checks every declared class is really on the
    addon, so the derived list cannot hide a missing export), and
    toml_lite.test.js derives the expected extra_imports count instead of
    hardcoding it.
  • scripts/benchmark.mjs gains --json and $NAPI_MOJO_BENCH_BATCHES; the
    human-readable table is unchanged.
  • CLAUDE.md referred to @qkstat/rag and packages/rag, which do not
    exist. The downstream packages are @qkstat/retrieve (which holds the GPU
    work) and @qkstat/embed.