Skip to content

npm run dev works on iOS: the restart storm, and eight fixes behind it - #17

Merged
ThyFriendlyFox merged 32 commits into
mainfrom
fix/rolldown-wasi-and-causes
Aug 12, 2026
Merged

npm run dev works on iOS: the restart storm, and eight fixes behind it#17
ThyFriendlyFox merged 32 commits into
mainfrom
fix/rolldown-wasi-and-causes

Conversation

@ThyFriendlyFox

Copy link
Copy Markdown
Collaborator

What & why

npm run dev did not work on iOS. A SvelteKit project in the simulator restarted
its dev server every ~3 seconds, never finished a dependency scan, and answered a
curl with a 500. It now starts once, pre-bundles its 20 dependencies, and returns
the rendered page.

The restart storm's cause is one edge case with a large blast radius. The project
sat at /, and chokidar records a directory under its parent —
_getWatchedDir(dirname(dir)).add(basename(dir)). For dir === "/", POSIX gives
dirname / and basename "", so it filed an empty-named child inside the root's
own record; the next read never listed "", the diff called it deleted, and
removing it resolved back to the root and tore the tree down — an unlink for
every file, vite.config.ts included. Vite restarted, built a new watcher, and
repeated. The dependency-scan failure was downstream of that, never a second bug.
A program now starts at a named root, so the path it watches has a real parent and
a real basename.

Eight more engine fixes came out from behind it, each found by a real project
failing:

  • ESM named imports were a snapshot, not a live binding, so a module exporting
    let x and assigning it later stayed frozen at undefined.
  • Svelte's compiler then ran half in dev mode: the live-binding rewrite skipped
    dev because svelte.dev inside a thrown error's URL read as a function
    parameter, and it mis-rewrote shorthand properties written one per line. The
    component emitted push_element but not the FILENAME it reads.
  • Request/Response bodies built from a Uint8Array went through String(body),
    so every SSR response arrived as comma-separated character codes.
  • error.stack carried no Name: message header the way V8's does, so every
    tool logging a stack printed frames and no reason — which is why diagnosis needed
    instrumented node_modules.
  • listen() with no host bound IPv4-only 0.0.0.0 where node binds dual-stack
    ::, so a program could not reach its own server by name (http.get({ port })).
  • lightningcss (native, no wasi build) is substituted with its authors'
    lightningcss-wasm; and file:/x now normalizes to file:///x as the URL
    standard requires — esbuild's import.meta.url polyfill emits the one-slash form.
  • import is a legal method name; vite's ModuleRunner uses it.
  • The /project alias no longer shadows a real directory of that name.

Also here, deferred behind the node work and asked for explicitly: tapping the
project's name in the Files container goes back to the picker.

Gesture-law impact

Upholds it. The one input change is a tap on a label — taps are content, and
this is a content element acting on its own container, like tapping a folder to
expand it. No drag, swipe, pinch or edge gesture is added, changed, or intercepted;
the lane swipe, divider and pinch behave exactly as before. The header keeps its
appearance and hit area (it gains contentShape so the row's empty space is part of
the target, not a larger visual footprint).

Verified — function and feel

On the iPhone 16 Pro simulator, driven through the app, not inferred from a build:

  • VITE v7.3.6 ready, printed once — no restart lines accumulated over hours.
  • curl http://localhost:5173/ from the Mac: HTTP 200, 33105 bytes, the rendered
    app. Repeated across every rebuild.
  • node_modules/.vite/deps/_metadata.json lists 20 pre-bundled dependencies
    the scan completing, not merely not erroring.
  • The watcher was exercised, not assumed, because silence is also what a dead
    watcher produces. Appending a line to src/routes/+page.svelte in the running
    workspace: ~3s later the curl returned 33133 bytes with the marker, and the
    terminal printed exactly one line — [vite] (ssr) page reload src/routes/+page.svelte. Reverting put it back. One reload, naming the file that
    changed: the original bug's inverse.
  • Header tap, with the dev server running — the case that matters: the picker
    appeared, curl was refused (000) and Mouse held no listeners at all, re-selecting
    the project restored the tree, and npm run dev served 200 again.

Suite: 143 assertions passed, 0 failed, 0 build-broken, plus the five harnesses
verify.sh declares investigations and does not count. Re-run at HEAD rather than
left at an earlier green, because TerminalSession changed after it.

Four gates added for behaviour that had none: watchroot (a watcher rooted at the
project root — verify/chokidar watches a subdirectory, which is why the storm was
invisible), stackshape (21 stack facts against real node), lightningcss,
tailwind, and leaveproject. Each was run against the pre-fix code and fails
there; a gate that passes both ways is decoration.

Not a SvelteKit special case: reactdev, hmr, firstrun (scaffold → install →
serve), vite, npmrun, tscwatch are green on the same engine.

Known, and not ours: Tailwind 4 cannot run here. @tailwindcss/oxide's wasi
build declares shared memory, JSC will not parse such a module, and
JSC::Options::useSharedArrayBuffer is restricted in Apple's build — settable by
name, still false in JSC_dumpOptions. Tailwind 3 has no native half, works, and
is gated. That bounds every napi-rs wasi binding built with threads.

  • Builds clean — no files added or removed under swift/, so xcodegen was not
    re-run; xcodebuild succeeds with zero warnings (two dead declarations in
    the transpiler removed on the way).
  • Feel-tested on a real device — simulator only. Everything above was driven
    on the iPhone 16 Pro simulator. The header tap is a static label with no
    animation, so there is little feel to judge, but this is not a hardware test
    and should not be read as one.
  • Screenshot/clip attached — screenshots were taken at each step (picker after
    the tap, the single HMR reload line, the file tree restored) but are not
    attached here. Happy to add them.
  • Docs updated — swift/README.md gains the Files container's way out,
    STATUS.md is reconciled at HEAD with the suite numbers, and the working brief
    was rewritten from 438 lines to 159 because its top said MET while its body
    still told the reader to chase things that were fixed.
  • No diagnostics or demo scaffolding left — the live workspace was audited for
    the node_modules instrumentation this needed (none remains), and seven
    checked-in debug leftovers were removed from verify/nodejs, including two
    captured copies of a passing run sitting beside the harness that prints it.

ThyFriendlyFox and others added 30 commits August 10, 2026 22:25
…hiding

`npm run dev` in a SvelteKit project died on the phone with rolldown's
"Cannot find native binding" and npm advice that is wrong on every count
here. Two real defects underneath, one expired assumption and one silence:

THE INSTALL HALF. `wasiBinding` found the `…-wasm32-wasi` build by reading a
package's optionalDependencies — and its comment said "rolldown, which vite 7
bundles with, is exactly this shape". The shape expired: rolldown 1.x lists
only its native bindings there, so nothing installed the wasi build and the
loader's last resort was absent. The name is now DERIVED from the natives'
own naming (`@scope/binding-<platform>-<arch>` → `…-wasm32-wasi`) when the
listing is gone, pinned to the package's exact version — napi-rs publishes
every binding in lockstep. A derived name is a guess about the registry, so
it resolves as optional: a package with no wasi build installs exactly as
before. verify/napiwasi rewritten against the REAL registry: derivation
lands rolldown's binding in lockstep, a listed binding still wins, and
@parcel/watcher — whose guessed name 404s — installs untouched. Proven able
to fail by emptying the platform-token list.

THE PRINTING HALF. node has printed `[cause]:` chains under uncaught errors
since 16.9; this engine printed the top error only. A library that wraps its
real failure (`new Error(summary, { cause })`) therefore showed ONLY the
summary — rolldown's npm advice, while the actual answer sat unprinted in
the cause. Both printers now walk the chain in node's shape (two more spaces
per level, frames under each message, remapped like the top error's): the
synchronous fatal path in Swift, and the unhandled-rejection formatter in
the bootstrap. Two deliberate divergences from node's inspect rendering,
recorded in the comments: no object braces, no stack elision. Verified
headlessly on the real engine — a three-deep synthetic chain prints
byte-shaped like node's on both paths.

MEASURED ON THE SIMULATOR, and worth recording precisely: with the binding
installed, `require('rolldown')` genuinely LOADED twice — real exports,
`build` included — and then failed twice in later runs of the same app
session, the loader recording only its native misses (it keeps wasi errors
in a separate array it never attaches). The thread-built wasm that the
macOS-host JSC refuses outright ("shared memory is not enabled") apparently
instantiates on the iOS-SDK engine — sometimes. rolldown on this engine is
FLAKY AT INSTANTIATION, not impossible; what gates that is future work, and
no claim of "vite 8 runs" is being made.

The working path for a SvelteKit project today, verified against the
registry's peer ranges: `npm install vite@7 @sveltejs/vite-plugin-svelte@6`
— rollup and esbuild ride the existing substitutions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…program

TWO BUGS THE PHONE FOUND, both reported by the owner, both reproduced on the
simulator before a line was changed.

1. `Server.listen()` ON AN ALREADY-LISTENING SERVER ALLOCATED A SECOND HOST
   SOCKET AND ORPHANED THE FIRST. On a machine whose process exits, an orphaned
   listening fd is invisible; here the engine outlives every program, so they
   accumulate — MEASURED AT 3607 LISTENING SOCKETS in one app session, all
   released the instant the app was terminated. vite then walked the port space
   reporting thousands of ports "in use" (they were: by us), which is the
   flashing storm in the owner's recording.

   node throws ERR_SERVER_ALREADY_LISTEN here, and that was implemented first
   and reverted, because it is the wrong answer for THIS engine: vite reaches
   the path legitimately. Its `createServerCloseFn` only closes a server it has
   seen emit 'listening' (`if (hasListened) server.close(…); else resolve()`),
   so a restart after a failed first bind re-listens a server it never closed.
   Throwing killed a dev server that had already printed its URL — measured,
   not predicted. The previous socket is now RELEASED and the listen proceeds:
   the leak is gone (nothing is orphaned) without inventing a fatal error on a
   path real packages take. The divergence from node is deliberate and is the
   conservative direction — node's throw protects a caller from LOSING a
   socket, and here the socket is closed rather than lost.

   A failed bind also clears `_sid` now: it was left set, which would have
   turned vite's legitimate EADDRINUSE retry into a false already-listening
   error.

2. A PROGRAM COULD HOLD THE TERMINAL FOREVER. `sv`'s closing screen finishes
   its work but leaves a raw-mode stdin listener alive; ^C is a BYTE in raw
   mode, the program ignores it, and esc/canc did nothing — the owner could not
   escape the menu. `canc` twice within ten seconds now takes the terminal
   back: `TerminalProgram.terminate()`, implemented by every program, and for a
   Node program the engine's own kill switch (the one `child.kill()` already
   used), so teardown runs the ORDINARY exit path and leaks nothing. Ten
   seconds, not two: the real cadence is tap, wait to see if anything happened,
   tap again, and a double-tap-sized window re-armed instead of killing.

VERIFIED ON THE SIMULATOR, against the owner's own SvelteKit project:
  - before: instant "Cannot find native binding", then a port storm.
  - after:  `npm run dev` BINDS AND SERVES — `curl http://localhost:5175/`
            answers HTTP 500 from vite's SSR module loader (a real vite
            response, not a refused connection), and the listener count stays
            bounded at 3 instead of 3607.
  - the wedge program (raw mode, swallows every key) is killed by canc twice:
    "killed node wedge.js", prompt returns.

Gates: verify/napiwasi 8 MATCH, verify/reqsock MATCH (4 lines identical to
node), verify/neterrors MATCH (5, including the 400).

NOT FIXED, and precisely diagnosed rather than guessed: vite still restarts in
a loop on this project. `vite.config.ts` mtime is CONSTANT (measured across
restarts) — the file never changes. vite writes
`node_modules/.vite-temp/vite.config.ts.timestamp-*.mjs` to load the config,
imports it and deletes it, and those temp-file events are reaching vite's
config watcher, which ignores `node_modules` on real node. Each event costs a
restart and one more bound port. That is the next thing to chase, in the
watcher's path filtering rather than in the socket layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Iteration 1 of the npm-run-dev loop. Two measurements killed the standing theory
and a third named the real trigger.

DEAD: 'our fs.watch fires spurious events'. NodeWatch.emit was instrumented with
NSLog for every event; during a full vite restart loop it logged ZERO. And a
chokidar 4.0.3 probe using vite's own options emitted only 'ready' — it MISSED a
control write of canary.txt into the watched root. Watching is broken in the
MISSING direction, not the spurious one.

IDENTIFIED: a stack capture at vite's config-change branch names the caller as
onFileAddUnlink (config.js:25654), so vite is told the config was ADDED, not
changed. Every restart builds a new chokidar watcher whose initial scan
re-announces vite.config.ts as an add, past ignoreInitial — which restarts the
server, which builds another watcher. That is the loop, and the same broken
watching explains why live edits are missed.

Next iteration has the probe written down: log adds and 'ready' with timestamps;
if adds land after ready, the fix is in our async fs scheduling.

No engine change yet — this commit is the brief only. The NSLog diagnostic in
NodeWatch.swift is still uncommitted and must be removed before any engine commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…f-fixed leak

Two things the next iteration must not repeat.

THE TRAP: after npm run dev the terminal belongs to the vite PROGRAM, so
anything typed goes to vite as keystrokes rather than to msh. Several
iteration-2 measurements were contaminated by exactly this — a capture file kept
showing the previous run's output because the new run never started. Confirm the
prompt is at '~ $' and that the command echoed before trusting a result.

THE LEAK IS HALF FIXED: b1133b6 releases the previous socket when the SAME server
re-listens, but vite's restart builds a NEW http.Server each time and never
closes the old one, so every restart still leaks one bound port — observed
climbing 5173 to 6087 within one app session. Mostly dissolves when the restart
loop is fixed, but a server dropped without close should still release its fd.

Also recorded: reading both chokidar copies did NOT settle where the stray 'add'
comes from, and cost a lot of cycles. Both gate add on initialAdd && ignoreInitial
and vite sets ignoreInitial true. Measure chokidar's own _emit instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Iteration 3, measured on a cleanly started server (the iteration-2 trap avoided).

A deep stack capture at vite's config-change branch shows the event type is
'delete', and not only for the config: /vite.config.ts, /static/robots.txt,
/.gitignore, /.npmrc and the rest. chokidar is telling vite that EVERY FILE in
the project was deleted, when nothing was. The config's unlink restarts the
server, the restart builds a new watcher, and it declares everything deleted
again. That is the loop. Iteration 1's guess of a leaked initial-scan 'add' was
wrong in the most useful way: the direction is the opposite.

Directory reading is ruled out as the cause. On device, in that project,
fs.promises.readdir('.', { withFileTypes: true }) returns 18 correct entries with
working isFile(), and readdirp('.') streams all 18. The reads chokidar leans on
are healthy.

Recorded for the next iteration: vite bundles its OWN chokidar inside
dist/node/chunks/config.js — node_modules/chokidar is a different copy — so the
instrumentation has to go in the inlined one, at _remove, which has exactly two
callers worth distinguishing: _handleRead's previous-vs-current diff, and
_handleFile's catch that removes when stat throws.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Iteration 4. Instrumenting the inlined chokidar's removal diff answered it in a
single run:

    REMOVE dir=/ item= currentSize=14 prevN=3

The removed item is the EMPTY STRING, and currentSize=14 shows the directory read
is healthy. chokidar's _handleDir does
_getWatchedDir(dirname(dir)).add(basename(dir)); for dir === '/', POSIX gives
dirname('/') === '/' and basename('/') === '' — node agrees, so our path module is
correct — and chokidar therefore files an empty-string child inside the root's OWN
record. The next read never lists '', the diff removes it, and removing '' resolves
back to the root and tears down the entire tracked tree, emitting unlink for every
file including vite.config.ts. vite restarts, builds a new watcher, and it happens
again about three seconds later.

That is the whole loop, and the dependency-scan failure hangs off it: each restart
aborts the in-flight esbuild scan with ERR_CLOSED_SERVER.

A project is never at / on a real machine, so dirname yields a real parent and the
edge case never fires there. Our virtual-filesystem layout is what triggers it,
which is why the fix belongs in swift/ and why it is general: it fixes watching for
any dev server, and HMR with it.

The brief records the two candidate fixes (give the workspace a non-root virtual
path via the existing mounts/cwd plumbing, versus special-casing watchers) and the
gate that was missing: nothing currently watches a tree rooted at the virtual root
and asserts no unlink arrives when nothing was deleted.

No engine change in this commit — the next iteration implements and gates it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Iteration 5 measured what the fix actually costs rather than starting surgery
that could not be finished or verified in one pass.

The trigger is one line — Shell.swift:1474 and :1485 launch node with
cwd: "/" + cwd, so at the workspace root a program's cwd is exactly '/'. But
changing the project's virtual root is not a one-line change: the engine is
'/'-rooted throughout. The resolver and loader emit mouse:/// paths, stack traces
and the source-context header print them, the Viewer's openFile hook receives
them, and msh clamps to the root. A rename surfaces in all of it.

So the brief now names two options with their real costs — root the workspace at
a named virtual path (correct, general, but changes every user-visible path), or
give only the WATCHED path a real basename via realpath (small blast radius, but
two names for one directory can desynchronise vite's constant root-relative path
math). Recommendation recorded: try the cheap reversible one first, behind a gate,
and fall back to the principled one if path comparisons break.

Also recorded: the missing gate. Nothing in verify/ watches a tree rooted at the
VIRTUAL ROOT and asserts no unlink arrives when nothing changed —
verify/chokidar watches a subdirectory, which is why it never caught this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Iteration 6. Before spending a cycle implementing the cheap option, I checked
whether its premise was true. It is not.

Option B assumed chokidar uses the REALPATH of the watch target for its
watched-directory bookkeeping, so that answering a named alias from realpath
would give the root a real basename. In vite's inlined chokidar, _handleDir uses
the path AS PASSED:

    parentDir = _getWatchedDir(dirname(dir))    // 13263
    tracked   = parentDir.has(basename(dir))    // 13264
    parentDir.add(basename(dir))                // 13268

realpath is only consulted for the _symlinkPaths check further down. So B could
never have removed the empty-basename child, and my previous recommendation to
try it first was wrong. Option A is the only fix.

The brief now carries A's plan against verified call sites: Shell.swift:1474 and
:1485 are the two launches that pass cwd: "/" + cwd, and NodeEngine.swift:2492
realURL(_:) is the single virtual-to-real resolver, which already does prefix
matching over mounts — so a named root can be added as an alias resolving to the
same workspace URL, leaving '/' working. Recorded too: what to watch during
verification (anything handing a program a '/'-rooted path after startup) and
that mouse:///project paths will show in traces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`npm run dev` on a real SvelteKit project looped forever: "vite.config.ts
changed, restarting server..." every ~3 seconds, the dependency scan dying with
ERR_CLOSED_SERVER each time, and a bound port leaked per restart. One cause, and
it was the project's own path.

chokidar records a directory under its parent:

    _getWatchedDir(dirname(dir)).add(basename(dir))

For dir == "/" that is dirname "/" and basename "" — POSIX, and node agrees, so
our path module was never wrong. chokidar therefore filed an EMPTY-NAMED child
inside the root's own record. The next directory read never lists "", the diff
called it deleted, and removing "" resolved back to the root and tore down the
whole tracked tree — an `unlink` for EVERY file in the project, vite.config.ts
among them. vite restarted, built a fresh watcher, and it happened again. The
dependency-scan failure was downstream: each restart aborted the in-flight
esbuild scan. No other platform hits this because no real project lives at the
filesystem root; our workspace-virtual layout put every project there.

The fix is that a program's cwd now has a real basename. The workspace answers to
`/project` as well as to `/` — one extra mount, resolved by the same prefix walk
`realURL` already did — and `Shell.swift` launches node with the named spelling.
Both spellings reach the same directory, so everything that already speaks "/"
is untouched.

MEASURED ON THE SIMULATOR, same project, before and after:

    before:  restart every ~3s, forever
             "(!) Failed to run dependency scan" every restart
             listeners climbing 5173 -> 6087 -> ... (3607 in one session)
    after:   VITE v7.3.6  ready in 6041 ms
             -> Local: http://localhost:5173/
             no restart, no scan failure
             ONE listener, on the FIRST port:
             Mouse  TCP [::1]:5173 (LISTEN)

Two things NOT fixed, stated plainly rather than left to be discovered:

  - `curl http://localhost:5173/` still answers 500. The server is up and
    routing; SvelteKit's SSR fails on a DIFFERENT bug, now visible because the
    loop stopped hiding it: "Error when evaluating SSR module
    @sveltejs/kit/src/runtime/server/index.js: runner.import is not a function
    ('runner.import' is undefined)". vite's SSRCompatModuleRunner extends
    ModuleRunner and the inherited method is missing — a class/prototype
    question in the engine, chased next.
  - No gate yet. The shape that would have caught this: watch a tree whose root
    is the program's root with chokidar, change nothing, assert no `unlink`
    arrives. verify/chokidar watches a SUBdirectory, which is why it always
    passed. That harness is the next commit.

Caveat recorded in the code: a workspace with a top-level directory named
"project" is shadowed by the alias, since prefixes match before the root
fallback.

The temporary NSLog diagnostic in NodeWatch.swift is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the restart storm gone, SvelteKit's SSR failed on the next thing down:

    Error when evaluating SSR module @sveltejs/kit/src/runtime/server/index.js:
    runner.import is not a function ('runner.import' is undefined)

The method really was missing, and we deleted it. `module-runner.js:1012` reads
`async import(url) { … }` — `import` is a perfectly legal method name — and the
transpiler's dynamic-import scanner saw `import(` and rewrote the DEFINITION into
`__dynamicImport(__mouseRequire, url) { … }`. The class then had no `import`, so
every SSR page load threw.

`.import(` was already guarded by the preceding dot, which is why calling it
looked fine; only the definition was destroyed. The guard now also skips a
`import(` that follows a method modifier — async, static, get, set. That cannot
miss a real dynamic import, because `async import(…)` is not a valid expression;
the modifier only ever precedes a definition.

Verified: verify/esmgrammar still MATCH (13 module shapes, 1 pinned divergence).

On the simulator, same project: the runner.import error is gone and SSR now runs
far enough to invoke svelte's own compiler. `curl localhost:5173/` still answers
500, but from a new and much deeper place — svelte's parser:

    read_tag@ …/svelte/src/compiler/phases/1-parse/state/element.js:946
    element@  …/1-parse/state/element.js:140
    Parser@   …/1-parse/index.js:129
    compile@  …/svelte/src/compiler/index.js:29
    @         …/@sveltejs/vite-plugin-svelte/src/utils/compile.js:96

Also confirms the named root is live: every frame reads mouse:///project/… .
Still one listener on 5173, no restarts.

Next: that parser throw. Its message is not in the response body — only the
stack — so the next iteration should capture the error's message/cause at
compile.js:96 rather than guess from frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Iteration 9. With the restart storm and the import-method bug fixed, SSR reaches
svelte's compiler, which throws at 1-parse/state/element.js:946 — 'start:
locator(start)'. locator is undefined, and it is our own recorded divergence that
makes it so.

    svelte/src/compiler/state.js:50   export let locator;      // undefined at import
    svelte/src/compiler/state.js:59   locator = (i) => { … };  // assigned later
    element.js:17                     import { locator } from '../../../state.js';

Real ESM named imports are live bindings, so locator becomes the function. Ours
are a snapshot — transpileESM emits const locator = __esmBinding(ns, 'locator') —
so it stays undefined forever. verify/esmgrammar has carried this for a long time
as '1 pinned divergence (named imports are a snapshot; the namespace form is
live)'. It is not cosmetic: it breaks Svelte, and any library that exports a let
and assigns it during initialisation.

The brief records what the fix needs — reading through the namespace at USE time,
which means identifier-level reference rewriting with scope awareness — and the
gate to write FIRST, a live-binding case that fails today: export let value, a
setter that reassigns it, and an importer that must observe 'after'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`export let locator;` filled in during init, imported by name, read as
`undefined` forever — that is what killed svelte's compiler, and it was our
oldest recorded ESM divergence: named imports were a COPY taken at import time,
where real ESM gives a live binding.

`transpileESM` now promotes a named import to a read through the namespace at
the point of USE — a reference rewrite, which is what every real bundler does.
It runs over the assembled body so the export prologue and epilogue move with it
(`export { x }` re-exporting an import would otherwise reference a local that no
longer exists).

Four ways a scanner can misread a name, each found by a real bundle failing to
parse, each now handled:

  - `f(a, named)`      a comma is not an object     -> walk back over balanced
                       pairs to the ENCLOSING bracket
  - `${ named }`       a template brace is not an   -> a `{` preceded by `$` is
                       object                          a substitution
  - `{ key: named }`   value position, not shorthand-> shorthand also needs the
                                                       name to sit right after
                                                       `{` or `,`
  - `from './send.mjs'` the mask keeps QUOTED text  -> explicit in-string check
                        verbatim so imports can read
                        specifiers

And a guard before any of it: `shadows(name)` refuses to promote a name the
module binds itself — a declaration, parameter, catch, or destructure — so an
ambiguous name keeps the old copy rather than being corrupted.

THE ESCAPE HATCH matters more than the guards. A reference rewrite over
third-party minified code is never provably safe; a miss shows up as a
SyntaxError. So a module that will not parse with live bindings is transpiled
again with copies and retried. Correct where it can be, never worse than before
where it cannot. fdir is the proof: it failed to parse three different ways
while I narrowed the rules, and now loads through the fallback regardless.

verify/esmgrammar: 14 module shapes MATCH real node, 0 pinned divergences. The
live-bindings case was PINNED for years with `after 0 2 1` against node's
`after 2 2 1`; it now matches and the pin is deleted. The harness caught the
stale pin itself ("PIN BROKEN"), which is the gate doing its job.

On the simulator the SvelteKit dev server now gets past vite's own startup and
into svelte's analyze phase — several layers deeper than the previous stop.
`curl localhost:5173/` still fails, inside
svelte/src/compiler/phases/2-analyze, so this is NOT the finish line; it is the
divergence removed and the next wall exposed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… own digits

Two fixes, both found by driving the real SvelteKit dev server.

1. LIVE BINDINGS MUST NOT REMOVE THE DECLARATION. Promoting a named import to a
   live read meant dropping its `const` and rewriting every reference. But the
   rewrite deliberately SKIPS any occurrence it cannot classify — and without a
   declaration to fall back on, a skipped name does not exist at all. svelte's
   a11y constants died as "Can't find variable: AXObjects" for exactly that
   reason. The `const` is now always emitted and promotion only ADDS live reads
   on top, so a missed reference reads the old snapshot: the previous behaviour,
   never a crash. The rewrite also skips declaration sites now (const/let/var/
   function/class), including the one we emit ourselves.

   Result: `require('svelte/compiler')` LOADS — measured against the real
   project tree, "LOADED function".

2. A RESPONSE BODY CAME BACK AS COMMA-SEPARATED DIGITS. `curl` returned
   "60,33,100,111,99,116,121,112,101,32,104,116,109,108,62,..." which decodes to
   "<!doctype html>" — the page's own bytes, printed as numbers. `__toBytes`
   handled Buffer, typed arrays and ArrayBuffer, then fell through to
   `String(value)`, and a plain ARRAY of byte numbers stringifies to its decimal
   listing. node treats `Buffer.from([…])` as bytes, so that is what this does
   now.

Progress on the simulator, same project: SvelteKit now RENDERS — the 500 that
comes back is SvelteKit's own "Internal Error" page, produced by its error
handler rather than a crash in module loading. It is served over the wire as
real HTML with this fix. Still a 500, so the goal is not met; but the request
now travels the whole route through vite, the module runner, svelte's compiler,
and back out as a page.

verify/esmgrammar: 14 shapes MATCH, 0 pinned divergences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`curl` was answering with "60,33,100,111,99,116,121,112,101,32,104,116,109,108,
62,..." — which decodes to "<!doctype html>". The page's own bytes, printed as
numbers.

Both `Request` and `Response` built their body with

    Buffer.isBuffer(body) ? body : Buffer.from(String(body))

and a Uint8Array is not a Buffer, so it went through `String()` and became its
comma-separated character codes. `new Response(new TextEncoder().encode(html))`
is exactly how a framework returns a rendered page, so every SSR response came
out as digits. Both now use `__toBytes`, the helper that already knew about
buffers, typed arrays, ArrayBuffers and strings — and which gained the
array-of-numbers case in the previous commit for the same reason.

Measured before and after, same expression:

    new Response(new TextEncoder().encode('<p>hi</p>')).text()
    before: "60,112,62,104,105,60,47,112,62"
    after:  "<p>hi</p>"

Gates: verify/fetchtypes 34 behaviours MATCH, verify/webstreams 26 MATCH,
verify/esmgrammar 14 shapes MATCH with 0 pinned divergences.

On the simulator the dev server now returns REAL HTML over the wire — the body
opens `<!doctype html><html lang="en">` and is 1378 bytes of page. It is still a
500: SvelteKit's own Internal Error page, thrown from inside svelte's SSR
renderer, whose stack now runs through push_element / child / head / _layout /
component / Root / #render. In other words the component tree is being rendered
and something inside it throws. That is the next thing, and its message is
logged above the stack in the terminal rather than in the response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`npm run dev` served SvelteKit's "Internal Error" page. The throw was inside
svelte's own SSR renderer:

    TypeError: undefined is not an object
      (evaluating 'context.function[__vite_ssr_import_0__.FILENAME]')

Svelte's `push()` sets `ssr_context.function` only when the compiler's `dev`
flag is on, and `push_element` reads it. Ours had the second and not the first:
the compiled component carried `push_element` calls but no `FILENAME`, no
`renderer.component(fn, _layout)` wrapper and no `.render` stub. Compiled by
real node, the same file has all four. The component was built HALF in dev mode.

`dev` is `export let dev` in the compiler's state module, assigned once per
compile — a live binding, the exact thing the ESM→CJS rewrite promotes. It was
left as a copy, frozen at the `undefined` it held when the module loaded, and
two separate misreadings of the source did it.

The shadow test asks whether a module declares a name itself, and it read the
INSIDE of strings. One of the errors svelte's compiler can throw ends with a
link to `https://svelte.dev/docs/...`, sitting inside the enclosing
`b.function(...)` call — so the parameter-list pattern found `dev` between
parentheses after the word `function` and concluded the module bound its own.
The shadow mask now blanks the inside of quoted strings, keeping the quotes and
the length; prose is not code. `function` also has to not be preceded by a dot,
because `b.function(…)` is a call to a method, and its arguments are not
parameters.

The second one would have broken the module even if the first had not. Deciding
whether a name is object shorthand walks left for the enclosing bracket and
right for what follows, and both walks skipped spaces and tabs but not
NEWLINES. A visitor table written one entry per line — which is how svelte
writes its — puts a newline between the comma and the name, so every entry
missed the shorthand test and became `{ _: set_scope, ns.AssignmentExpression }`.
That does not parse, and a module that does not parse falls back to snapshot
bindings whole, taking `dev` with it. Newlines are whitespace in both walks now.

Measured on the project's own `+layout.svelte`, compiled through our engine
with `dev: true`:

                        before   after   real node
    $.FILENAME            no      yes      yes
    renderer.component(   no      yes      yes
    push_element         yes      yes      yes
    .render stub          no      yes      yes

And the dev server, curled from the Mac against the simulator:

    before: HTTP 500, 1378 bytes, SvelteKit's Internal Error page
    after:  HTTP 200, 33105 bytes, the rendered app
    VITE v7.3.6 ready in 19343 ms, started once, no restart loop

Gate: a new esmgrammar case, `live-through-prose-and-tables`, carries both
shapes — a live `dev` read across a thrown message containing `svelte.dev`, and
a shorthand visitor table one entry per line. Against the engine as it was, it
prints `,nb,assign` twice where real node prints `true,nb,assign` the second
time. ESM GRAMMAR MATCH — 15 module shapes, 0 pinned divergences.

The transpile cache version moves to 8; v7 output is wrong for any module this
touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Failed to run dependency scan. Skipping dependency pre-bundling." outlived the
restart storm that caused it, so it looked like a second, separate fault. It is
not. The scan is asynchronous and vite reports `ERR_CLOSED_SERVER` when the
plugin container closes under it — during a restart, or when a harness closes
the server while the scan is still running, which is what the reproduction here
was doing. The esbuild text under it, once read rather than inferred:

    Build failed with 1 error:
    node_modules/@sveltejs/kit/src/runtime/app/paths/index.js:1:14:
      ERROR: [plugin: vite:dep-scan] The server is being restarted or closed.
             Request is outdated

Left open for 25 seconds and served twice, no such error appears, and the
positive evidence is on disk: `node_modules/.vite/deps/_metadata.json` lists 20
optimized dependencies — svelte, svelte/internal, svelte/internal/client and the
rest — so the scan ran to completion and pre-bundled them.

The brief now records the goal as met, with the numbers, and carries what is
still open: JSC's `error.stack` has no `Name: message` header, so anything that
logs a stack prints frames with no message — the single largest cost in this
loop; no gate watches a tree rooted at the program root for spurious `unlink`,
which is why the restart storm was invisible to the suite; and tailwind is still
out of the test project's vite config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lash

Tailwind 4 pulls in lightningcss through `@tailwindcss/node`, and vite reaches
for it as a CSS transformer. It is a native module with no wasi fallback: the
loader tries `lightningcss-<platform>-<arch>`, then a sibling `.node`, and on a
phone that is simply

    Cannot find module '../lightningcss.darwin-arm64.node'

Its authors publish `lightningcss-wasm` at the same version, and unlike most
wasm ports it is substitutable: the `node` export condition reads its own wasm
with `fs.readFileSync`, instantiates it at module scope, and hands back the same
SYNCHRONOUS transform/bundle/Features/composeVisitors surface. No `await init()`
for the caller, which is the whole reason it can stand in. It joins rollup and
esbuild in `wasmSubstitutes`.

Installing it was not enough, and the second half is the more general bug. The
substitute finds its own wasm through

    fs.readFileSync(new URL('lightningcss_node.wasm', import.meta.url))

and in the CJS build `import.meta.url` comes from esbuild's polyfill, which is
literally `new URL('file:' + __filename)`. `file:` is a SPECIAL scheme in the
URL standard: one slash after it means the same URL as three, and `localhost`
IS the empty host. Ours kept the text as written, so the URL stayed
`file:/project/…`, `fs` never recognised the prefix, and it went looking for a
file whose name began with "file:". Now:

                                     ours before        ours now / node
    new URL('file:/a/b.js').href     file:/a/b.js       file:///a/b.js
    new URL('file://localhost/a')    file://localhost/a file:///a
    new URL('c', 'file:/a/b/i.js')   file:/a/c          file:///a/c
    fileURLToPath('file:/a/b.js')    throws             /a/b.js

`http:/host/x`, which the standard also re-reads as an authority, is left alone
— that is a different state in the parser and no one here depends on it.

Gates: `verify/fileurl` grows from 38 to 43 vectors, byte-identical to node,
covering the one-slash form, the bare scheme, localhost, relative resolution
from a one-slash base, and `fileURLToPath`. A new `verify/lightningcss` installs
the package through our own package manager and asserts the substitute landed
under the original name AND transforms real CSS in this engine, synchronously —
`#ff0000` comes back as `red`, minified, not a promise. 9 checks.

Also green after the change: urlparse, urlformat, urlresolve, fetchtypes (34),
pkg, napiwasi (8). On the simulator, `npm run dev` still answers a curl from the
Mac with HTTP 200 and 33105 bytes.

Tailwind itself still cannot run, one layer further in, and the brief now
records why with the measurement: `@tailwindcss/oxide`'s wasi build declares
shared memory, JSC refuses to parse such a module, and the option that gates it
is restricted in Apple's build — settable by name, still false in
`JSC_dumpOptions`. That bounds every napi-rs wasi binding built with threads,
so it is worth having written down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The restart storm came from a project rooted at "/": chokidar files a directory
under its parent, and for the root POSIX gives dirname "/" and basename "", so
it recorded a child named "" inside the root's own record, decided on the next
read that the child was gone, and removing "" resolved back to the root and tore
the tree down — an unlink for every file in the project, `vite.config.ts`
included. Vite restarted, built a new watcher, and did it again every three
seconds. `verify/chokidar` was green the whole time, because it watches a
SUBDIRECTORY.

`verify/watchroot` watches the project root. It drives MouseShell rather than
the engine, because the fix lives in the launch path — a program starts at the
named root, so the path it watches has a real parent and a real basename — and
a harness that called the engine directly would pass whatever cwd it liked and
prove nothing. Five checks: the launch cwd is not "/", it is the named root,
writing into the root deletes nothing, the config file in particular survives,
and a real add still arrives afterwards. That last one matters: a watcher that
died would also report no unlink.

Run against the previous launch path — the two sites in Shell.swift that passed
`"/" + cwd`, reverted in a scratch copy — it fails:

    FAIL: a program does not start at "/" — it starts at /
    FAIL: the launch cwd is the named root /project, got /

So it catches the regression. It does NOT reproduce the storm, and the harness
says so in its own comment rather than implying otherwise: standalone chokidar
over a root this size stayed quiet even from "/", and the tearing needed the dev
server's own watcher and its own churn. The unlink assertions are an invariant
held, not a reproduction fixed.

Two dead things in the transpiler went with it. `epilogue` was declared, joined
onto every module body, and never once appended to — always the empty string —
which is the `epilogue` in a warning that has been in the build output long
enough for someone to ask what it meant. Its mention in a nearby comment now
names what actually happened rather than a variable that no longer exists.
`keywordEnd` was a `var` that is never assigned twice. The engine's build is at
zero warnings.

esmgrammar still MATCHES on 15 shapes with 0 pinned divergences, and on the
simulator the rebuilt app answers a curl from the Mac with HTTP 200 and 33105
bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
V8 writes a stack as "TypeError: message" and then "    at fn (file:line:col)".
JSC writes the frames alone, "fn@file:line:col", and no first line at all. Every
tool that logs `err.stack` therefore printed frames and no reason on this engine.
That is not a small thing: SvelteKit's format_server_error prints `error.stack`
and nothing else, so the page that failed to render in this loop showed forty
frames and never said why, and finding the message meant instrumenting
node_modules. Vite's ssrRewriteStacktrace matches `/^ {4}at /`, so no SSR frame
was ever mapped back to the .svelte file it came from either.

The error constructors are now wrapped. Every error JAVASCRIPT builds gets a
header and V8-shaped frames.

What that does NOT cover, stated up front: an error the ENGINE throws. JSC
materialises `stack` as an own data property while the error is being
constructed, on its own errors as much as on ours, and no hook sees that moment
— so a bare `undefined is not an object` read through `.stack` still has no
header. console and util.inspect rebuild it for those, as they already did.
Libraries throw their own errors deliberately, and those are the ones that carry
the message a user needs, so this is most of the value and not all of it.

Details that took a measurement each. The frames are built LAZILY behind an
accessor: the string costs a split and a join over every frame, and most errors
are constructed, caught and never asked for a stack — it also matches V8, where
`stack` is lazy, and vite's rebindErrorStacktrace reads the descriptor before
overwriting and takes the configurable branch either way. The wrapper's own
frame is cut at a named marker rather than by counting, because counting left
`at Wrapped (<anonymous>)` as the innermost frame — there are native frames
under it that a fixed offset does not know about. The native prototype is kept
as the wrapper's, so `instanceof` still answers for errors the engine threw, and
`Error.prototype.constructor` is repointed so `e.constructor === Error` holds.
`TypeError.__proto__ === Error` is preserved because code reads it.

The payoff, on the dev server, same provoked failure before and after:

    before: (!) Failed to run dependency scan. Skipping dependency pre-bundling.
            failureErrorWithLog@…/esbuild/lib/main.js:1742:24
    after:  (!) Failed to run dependency scan. Skipping dependency pre-bundling.
            Error: Failed to scan for dependencies from entries:
              /project/src/routes/+layout.svelte
              /project/src/routes/+page.svelte

Gate: `verify/stackshape`, 21 facts compared with real node — the header for
every error kind the language defines, V8 frame format, the innermost frame
being the thrower rather than the constructor, subclasses keeping their name,
and a stack still configurable, settable and redefinable. Against the engine as
it was, eleven of those lines differ.

Also green, all rebuilt against this: stackline (frames still name the lines node
names), errcodes 18, errpaths 5, assertmsg 18, console, inspect 41, rejection 14,
globals 52, propgaps 9. On the simulator the rebuilt app answers a curl from the
Mac with HTTP 200 and 33105 bytes.

`verify/nodejs` has one mismatch, `event-sequences`, where two http fixtures time
out. It is NOT from this change: the same harness built against the previous
engine produces the same 104 lines and the same single mismatch. Recorded in the
brief rather than quietly left in the output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`verify/nodejs` had one mismatch, `event-sequences`, where two http fixtures
reported TIMED OUT. Both used `http.get({ port })` with no host. The socket
fixtures beside them, which pass '127.0.0.1' explicitly, passed — and that is
the whole shape of the bug.

`listen()` with no host bound "0.0.0.0". Node binds `::` with IPV6_V6ONLY off:
one dual-stack socket that answers IPv6 and IPv4 alike. `localhost` resolves to
::1 FIRST on this platform — both engines agree, measured — so a program that
called its own server by name got ECONNREFUSED from a server it had just
started. `http.get({ port })` is how node's own documentation writes a request,
and it could not reach a server this runtime had opened a moment earlier.

    listen(0), then a request to…      before        after / node
    no host given                      ECONNREFUSED  200
    host '127.0.0.1'                   200           200
    host 'localhost'                   ECONNREFUSED  200
    host '::1'                         ECONNREFUSED  200
    address() reports                  0.0.0.0       ::

Two halves. The JS side passed '0.0.0.0' as its default, throwing away the fact
that no host was given before the socket layer could act on it; it now passes
the empty string. The socket layer asks getaddrinfo for the wildcard, tries
IPv6 first because only a v6 socket can carry both families, and clears
IPV6_V6ONLY. A port already in use still fails as EADDRINUSE rather than
quietly retrying the other family — callers act on that code, and a second
server on a busy port is not a kindness.

`verify/nodejs` now reports PHASE G: ALL PASS. Also green, rebuilt against this:
net, http, httpclose, movedport, reqsock, neterrors, unixsock, sse, express. On
the simulator, HTTP 200 and 33105 bytes to a curl from the Mac.

`verify/devserver` is the other half of this commit, and it is a correction. It
failed once during the sweep, and I read that as a regression I had caused —
bisected it across five commits, built four engines, and concluded the error-
stack work had broken it. Then the same unmodified binary passed twice in a row.
It slept a fixed six seconds waiting for vite to boot, which is enough on an
idle machine and not enough beside a parallel build; it now polls for the server
and passes with three CPU burners running. A gate that fails only under load is
the worst kind, because a flake and a regression read identically and the next
person believes whichever they saw first. The harness says so where the wait
used to be.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The brief asks for this to be general — "React/Vite and any other node dev
server, not a SvelteKit special case" — and that had been asserted all loop
without being re-checked. It holds. Rebuilt against the engine as it now stands:

    reactdev   vite served .tsx with types erased and JSX compiled
    hmr        the watcher saw the edit and pushed it down a real WebSocket
    firstrun   `npm create vite` scaffolded, installed and served, end to end
    vite       the dev server AND a rollup build
    npmrun     all 8 behaviours, pre/post hooks and argument passing
    tscwatch   the compiler watched, recompiled and reported

Six for six, so the SvelteKit page is one instance of a working path rather than
the path.

The other half is deletion. `verify/nodejs` carried seven tracked files that
were somebody's afternoon: `dbg.swift` and `dbg2/main.swift` (the same chalk
install probe, twice), `dbg3/main.swift` and `dbg3main.swift` (a three-line
transpile dumper), `sdbg/main.swift` (a stream probe), and `s.txt` and
`suite.txt` — two captured runs of the harness's own output, one of them ending
in "PHASE G: ALL PASS", checked in beside the harness that prints it. Nothing in
the repository references any of them; the harness builds and runs without them.
AGENTS.md asks for no leftover diagnostics, and a checked-in copy of a passing
run is worse than clutter — it reads as a fixture, and someone will eventually
diff against it.

I did not write these and did not delete them on sight: each was read, and the
tree and both scripts were searched for references first.

STATUS.md now counts the suite as it is — 146 harnesses — and names the three
added during this loop: `lightningcss`, `watchroot`, `stackshape`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A full run of all 146 harnesses — the first this loop, and the reason to do it —
came back 139 passed, 2 failed, and the two failures were one bug:

    axios   Cannot find module './project/node_modules/axios'
    jest    Cannot find module './project/node_modules/jest'

Both harnesses install their packages into a directory called `project`, and
this branch reserves `/project` as the alias a program gets as its cwd. The
alias is matched before the root, so `/project/node_modules/axios` reached
`<workspace>/node_modules/axios` — which does not exist — and the real directory
could not be addressed at all. It was written down in `init` as a caveat. A
caveat that two of a hundred and forty-six harnesses trip over on the first full
run is a defect, and it would have met a user whose repository has a top-level
`project/` — a common enough name.

The name is chosen now rather than fixed: if the workspace already holds an
entry called `project`, the alias becomes `/project-2`, and both paths stay
reachable with no ambiguity about which one a path means. Deciding it per engine
is what makes that possible, so `namedRoot` moved from a static to an instance
property and callers read it from the engine they are using; `defaultNamedRoot`
remains for the name it starts from.

`verify/watchroot` now asks an engine over the same workspace what the name is
instead of hardcoding it — there is no single right answer to hardcode any more,
which is the point.

After the fix: axios MATCH on all 9 behaviours, jest MATCH on 14 results across
a cold and a warm run, and watchroot, lightningcss and stackshape still green.
The device is unaffected — `local__test-2` has no `project/` directory, so its
alias is unchanged — and the rebuilt app still answers a curl from the Mac with
HTTP 200 and 33105 bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-ran all 146 harnesses against the final tree rather than trusting the run
from before the alias fix, because that fix changed `NodeEngine.init` and the
last full run predated it.

    141 assertions passed, 0 failed, 0 build-broken
    5 diagnostics, not counted: breadth, glob, reachable, shapes, vitestrun

Those five are the exact set `verify.sh` names as investigations rather than
assertions — corpora and sweeps that report differences by design — so the
result is clean and not five failures wearing a different word.

STATUS.md's reconciliation line moves from 2026-07-31 at 953dd98 to today at
dbd5d2d, with the numbers, since a date and a hash with no result behind them is
the kind of claim this file exists to avoid.

The device, unchanged and still running the build from the previous commit,
answers a curl from the Mac with HTTP 200 and 33105 bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every check so far read the watcher's silence as health: the server started
once, nothing restarted, the log stayed quiet. Silence is also what a dead
watcher produces, and this loop began with a watcher that was anything but
quiet — so the last thing worth doing was making it move, on the device, and
watching what it did.

Appending one line to `src/routes/+page.svelte` in the running workspace:

    ~3s later   curl from the Mac returns 33133 bytes, up from 33105, marker in the HTML
    terminal    exactly one line — `[vite] (ssr) page reload src/routes/+page.svelte`
    reverted    ~3s later, 33105 bytes again, marker gone, file byte-identical to before

One reload, naming the file that actually changed, in a project rooted where
chokidar used to file an empty-named child and tear the tree down. It is the
original bug's exact inverse: then, every file in the project reported deleted
and nothing had changed; now, one file reports changed and it is the one that
did.

Also audited, because the brief requires it rather than because anything looked
wrong: the live workspace carries none of the diagnosis instrumentation this
loop needed — no `__drive.mjs` or `__compile.mjs`, no `hooks.server.js`, no
`>>>` probes left in svelte's context.js or esbuild's main.js, no `.orig`
directories from the hand-swapped lightningcss, and vite.config.ts in the state
the brief describes. Nothing to revert; it was checked, not assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Tailwind does not work here" is what the brief said, and it is coarser than the
truth in the direction that costs someone a project. Only version 4 is blocked.
Its scanner, `@tailwindcss/oxide`, is a napi-rs binding whose wasi build
declares shared memory; JavaScriptCore will not parse such a module, and the
option that would allow it is restricted in Apple's build. Version 3 has no
native half at all — scanner and compiler are both JavaScript — so it simply
runs, and nobody had checked.

Measured on this engine, tailwind 3.4 through postcss:

    .mt-4 { margin-top: 1rem } .text-center { text-align: center }
    .font-bold { font-weight: 700 }

compiled from `content: [{ raw: '<div class="mt-4 text-center font-bold …">' }]`
— which means the content scanner, the part people assume cannot work off a real
filesystem, read the markup and decided what to emit.

`verify/tailwind` pins seven things rather than just "it compiled": the package
lists no per-platform binary (so a future 3.x that grows one is caught here),
plain utilities, an arbitrary value `w-[37px]`, a `hover:` variant, a `md:`
breakpoint reaching a real `@media` rule, a sheet with actual content, and — the
one that proves the scanner rather than a dump — a class the markup never used
NOT being emitted.

The brief and STATUS.md now say which version, because the difference is the
difference between "can't use tailwind" and "use tailwind 3".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
STATUS.md said 146 and the tailwind gate had just made it 147. A count that
drifts is exactly the kind of unbacked number this file exists to keep out, and
it drifted within one commit of being written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The loop's first instruction is to read this file, and its top has said MET for
a while now while three hundred lines underneath still told the reader to chase
things that are fixed. A document whose opening contradicts its body is worse
than a stale one, because the reader who scrolls trusts what they find.

What it was telling them to do, all of it wrong now: instrument `handleHMRUpdate`
to find the restart trigger (found, and fixed); pick between option A and option
B for the root (picked, and B is proven impossible); write the watcher gate
(written — `verify/watchroot`); and remove a TEMP-DIAGNOSTIC NSLog block from
`NodeWatch.swift` before committing. That last one sent me looking: there is no
NSLog anywhere in `swift/Mouse`. It was removed long ago and the note outlived
it, which is exactly how a reader loses faith in a file.

Kept, because it still teaches: the root cause with its captured
`REMOVE dir=/ item=` line; the correction that a realpath alias CANNOT work,
since chokidar bookkeeps on the path as passed in — worth having if anyone
revisits the root design; the device probes already ruled out, so nobody repeats
them; and the measurement trap, where the terminal is owned by the dev server
and keystrokes go to vite rather than msh.

Added: re-run a red gate before believing it, with the three times this loop
believed one too early.

Dropped: the superseded plans, the dead "chase this first" theory about
NOTE_ATTRIB, the iteration-by-iteration narrative that the root cause section now
subsumes, and the stale instrumentation note. All of it is in git history at
4b02ca7 and before if it is ever wanted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Asked for during the node work and deferred to it: "we don't have functionality
where tapping the local/test header would bring you back to the project picker,
but that is a good idea so I want that implemented. AFTER you fix the node
issue." The node issue is fixed.

The name in the Files container is now the control, the way a breadcrumb's root
is. It keeps the label's exact appearance — same font, same 0.55 opacity — and
nothing announces that it can be tapped, because the ask was for the behaviour
and this repo does not narrate its own affordances.

Leaving takes the project's things with it, which is the part worth explaining.
The ring holds a terminal session memoized on the workspace root, plus the open
file. Setting the workspace to nil and walking away would have left a running
dev server bound to its port with no screen able to reach it — the "a running
program could not be stopped at all" trap in a new costume, and the user would
have had to kill the app to get the port back. So `leaveWorkspace()` stops the
session first, then drops it, the open file, and the workspace.

Stopping it is deliberate rather than polite: `stopForProjectChange` uses the
close-button path, not a ^C. Vite treats ^C as a keystroke and keeps serving, so
asking nicely would have orphaned exactly the server this loop spent its time
on. The forced-kill body that canc's second press already used is now a shared
`forceStop`, so both routes stop a program the same way.

Verified on the simulator with the dev server RUNNING, which is the case that
matters:

    tapped `local/test-2`     the picker appeared
    curl localhost:5173       refused (000), and Mouse held no listeners at all
    re-selected test-2        the file tree came back
    npm run dev               HTTP 200, 33105 bytes again

Not gated, and I would rather say so than imply otherwise. The stop path is
reachable from a harness — `TerminalSession.run`/`launch` are public and
`verify/termsays` and `verify/tuinline` already drive sessions — but asserting
the program case needs the `launchProgram` wiring `verify/devserver` builds, and
bolting that on at the end of a UI change is how a gate ends up testing the
wrong path. It is the next thing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Last commit shipped the header-tap-to-picker behaviour ungated and said so. This
is the gate, and it is the case that matters: a project change while a dev
server is running.

`verify/leaveproject` drives the same TerminalSession the app owns. It starts a
node http server through the full-screen launch path — the path `npm run dev`
takes, asserted rather than assumed, since a gate that stopped a program the app
never starts that way would be testing the wrong thing — waits for the port to
answer, calls `stopForProjectChange`, and then requires the port to stop
answering and the screen to be reclaimed.

The first check is there so the gate cannot pass vacuously: proving a port is
closed proves nothing if the server never opened it.

It has teeth. Built against a TerminalSession whose `stopForProjectChange` does
nothing — which is exactly what dropping the session used to do — two checks
fail:

    ok:   the server answered while the project was open
    ok:   the program owns the terminal — the path npm run dev takes
    FAIL: the port stops answering once the project is left
    FAIL: the screen is reclaimed — no program left installed

That is the orphan, reproduced: a server still serving with nothing able to
reach it.

148 harnesses now; STATUS.md counts them. The device is untouched by this commit
and still answers with HTTP 200 and 33105 bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous reconciliation named dbd5d2d, which was honest but had started to
trail: `TerminalSession` changed in 0873ca0 for the project-change stop, and a
reader glancing at STATUS could reasonably have taken the green as covering it.

Whole suite at 755a1c1:

    143 assertions passed, 0 failed, 0 build-broken
    5 diagnostics, not counted: breadth, glob, reachable, shapes, vitestrun

143 rather than 141 because `tailwind` and `leaveproject` joined since. The five
diagnostics are the same set `verify.sh` names as investigations, so nothing
changed shape.

The targeted run first — devserver, termsays, tuinline, signals, exitcode,
shell, npmrun, the harnesses that actually exercise the refactored `^C`
escalation — was also clean, which is what made the full run a confirmation
rather than a search.

Device unchanged and still answering with HTTP 200 and 33105 bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox and others added 2 commits August 12, 2026 02:30
The brief said an engine-thrown error's missing stack header had "no hook", full
stop. That is true about creation and misleading about the problem: `stack` is
writable, and the engine already rewrites every ESM module, so `catch (e) {`
could become `catch (e) { __mouseFixStack(e);` and give the error its header at
the moment code catches it — which is precisely where SvelteKit caught the
TypeError this loop spent its first days chasing.

I am not doing it, and the reasons belong next to the idea rather than in my
head. It would be the fifth rewrite in `transpileESM`, and two of the four
already there shipped bugs that only a real bundle failing revealed — a URL
inside a thrown message, and shorthand properties across newlines. It covers
only transpiled ESM, so the CJS chunk vite inlines its own chokidar into stays
uncovered. And it needs a gate of its own for `catch` with no binding, for a
rethrown `e`, and for the word `catch` inside a template or a comment.

That is a piece of work for a fresh context with room to gate it properly, not
something to start with little left. Recorded so the next session inherits the
analysis instead of the dead end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
swift/README.md describes what the Files container does — the tree, the taps
that expand and open, "+ add file" — and since the last commit its first line
also goes back to the picker. Docs move with behaviour, so it says that now,
including the part a reader would not guess: leaving stops whatever the ring was
running, outright rather than with a ^C, because a dev server reads ^C as a
keystroke and keeps its port.

Found by working through the PR template's "docs updated if behavior changed"
question rather than by ticking it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ThyFriendlyFox
ThyFriendlyFox merged commit 4480d47 into main Aug 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant