From d27ffdd81b5ab06a301af0a4b4a81001d4a3f319 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Mon, 10 Aug 2026 22:25:05 -0400 Subject: [PATCH 01/32] rolldown's unlisted wasi binding, and the [cause] chains errors were hiding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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--` → `…-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 --- swift/Mouse/NodeEngine.swift | 62 ++++++++++++++ swift/Mouse/PackageManager.swift | 45 ++++++++-- verify/napiwasi/main.swift | 138 +++++++++++++++++++------------ 3 files changed, 184 insertions(+), 61 deletions(-) diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index ba73769..9f7b140 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -386,6 +386,7 @@ final class NodeEngine: @unchecked Sendable { if !handled, exitCode == nil { err += sourceContext(of: fatalValue) err += remapStack(fatal.hasSuffix("\n") ? fatal : fatal + "\n") + err += causeChain(of: fatalValue) exitCode = 1 } } @@ -436,6 +437,42 @@ final class NodeEngine: @unchecked Sendable { return Result(out: out, err: err, status: exitCode ?? 0) } + /// The `[cause]:` chain under an uncaught error, the way node has printed it since 16.9 — + /// each level indented two more spaces, message first, frames remapped like the top error's. + /// Without this, a library that wraps its real failure (`new Error(summary, { cause })`) + /// prints only the summary: rolldown's loader threw "Cannot find native binding" with npm + /// advice attached, while the actual refusal — JavaScriptCore declining a thread-built wasm + /// module — sat unprinted in the cause. The one honest error surface was hiding the honest + /// error. + /// + /// Two deliberate divergences from node's rendering, which is `util.inspect`'s: no `{`/`}` + /// object braces around nested errors, and no "... N lines matching cause stack trace ..." + /// elision — the plain chain reads better on a phone column and elides nothing. + private func causeChain(of error: JSValue?) -> String { + var text = "" + var current = error?.forProperty("cause") + var depth = 1 + // A cause cycle would loop forever; eight levels prints every chain a real package + // throws (rolldown's is six) without needing identity tracking across JSValues. + while let value = current, !value.isUndefined, !value.isNull, depth <= 8 { + let message = value.toString() ?? "?" + // JSC stacks carry frames only — no leading "Error: message" line like V8's. + var body = message + if value.isObject, let stack = value.forProperty("stack"), !stack.isUndefined, + let frames = stack.toString(), !frames.isEmpty { + body += "\n" + remapStack(frames.hasSuffix("\n") ? String(frames.dropLast()) : frames) + } + let indent = String(repeating: " ", count: depth) + let lines = body.split(separator: "\n", omittingEmptySubsequences: false) + for (index, line) in lines.enumerated() { + text += indent + (index == 0 ? "[cause]: " : " ") + line + "\n" + } + current = value.isObject ? value.forProperty("cause") : nil + depth += 1 + } + return text + } + /// `sourceURL` names the module in stack traces. Without it JSC reports every frame as a /// bare `functionName@` — unreadable for anyone debugging a real error in the terminal, /// which is the app's one honest error surface. @@ -6468,6 +6505,31 @@ final class NodeEngine: @unchecked Sendable { const stack = reason.stack; text = stack && String(stack).indexOf(reason.message) >= 0 ? String(stack) : String(reason) + (stack ? '\n' + stack : ''); + // The `[cause]:` chain, as node prints it since 16.9 — each level indented two more + // spaces, frames under the message. A library that wraps its real failure + // (`new Error(summary, { cause })`) otherwise prints ONLY the summary: rolldown's + // loader says "Cannot find native binding" with npm advice, while the true refusal — + // the engine declining a thread-built wasm module — sat unprinted in the cause. No + // object braces and no stack elision, unlike node's inspect: the plain chain elides + // nothing, which on a phone column is the point. + let cause = reason.cause; + let depth = 1; + while (cause !== undefined && cause !== null && depth <= 8) { + const indent = ' '.repeat(depth); + let body = String(cause && cause.message !== undefined ? cause : cause); + if (cause instanceof Error) { + body = String(cause); + if (cause.stack && String(cause.stack).length) { + body += '\n' + String(cause.stack).replace(/\n$/, ''); + } + } + const lines = body.split('\n'); + for (let i = 0; i < lines.length; i++) { + text += '\n' + indent + (i === 0 ? '[cause]: ' : ' ') + lines[i]; + } + cause = (cause instanceof Error || typeof cause === 'object') && cause ? cause.cause : undefined; + depth += 1; + } } else { // node wraps a non-Error rejection rather than printing it bare. text = 'UnhandledPromiseRejection: This error originated either by throwing inside of ' + diff --git a/swift/Mouse/PackageManager.swift b/swift/Mouse/PackageManager.swift index 10d37aa..d3828dc 100644 --- a/swift/Mouse/PackageManager.swift +++ b/swift/Mouse/PackageManager.swift @@ -410,16 +410,34 @@ enum PackageManager { "esbuild": "esbuild-wasm", ] - /// The same idea as `wasmSubstitutes`, but for the shape napi-rs uses: a package lists every - /// platform's binary in `optionalDependencies` and its loader tries them in turn, ending at - /// `…-wasm32-wasi` — the author's own portable build, published for platforms they do not - /// ship a binary for. Every native one is skipped here (none can load on iOS) and that one - /// is installed, so the loader's last resort is the one that is present. rolldown, which - /// vite 7 bundles with, is exactly this shape. - static func wasiBinding(of package: ResolvedPackage) -> (name: String, requirement: String)? { + /// The same idea as `wasmSubstitutes`, but for the shape napi-rs uses: a package lists its + /// platform binaries in `optionalDependencies` and its loader tries them in turn, with + /// `…-wasm32-wasi` — the author's own portable build — as the last resort. Every native one + /// is skipped here (none can load on iOS) and the wasi one is installed, so the loader's + /// last resort is the one that is present. + /// + /// Two shapes, because the ecosystem moved. Originally the wasi build sat IN + /// `optionalDependencies` and this function just found it. rolldown 1.x STOPPED listing it + /// — the natives are all that remain — which on a phone turned `npm run dev` of a vite 8 + /// project into rolldown's "Cannot find native binding" spew: the loader's wasi branch + /// probes `@rolldown/binding-wasm32-wasi` by name, but nothing had installed it. So when + /// the listing is gone, the name is DERIVED from the natives' own naming shape + /// (`@scope/binding--` → `…-wasm32-wasi`), 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 rather than failing the whole tree. + static func wasiBinding(of package: ResolvedPackage) -> (name: String, requirement: String, derived: Bool)? { for (name, requirement) in package.optionalDependencies.sorted(by: { $0.key < $1.key }) where name.hasSuffix("-wasm32-wasi") { - return (name, requirement) + return (name, requirement, false) + } + for name in package.optionalDependencies.keys.sorted() { + for platform in ["-darwin-", "-linux-", "-win32-", "-freebsd-", "-android-", "-openharmony-"] { + if let range = name.range(of: platform) { + let stem = String(name[.. PackageManager.ResolvedPackage { + PackageManager.ResolvedPackage( + name: "fake", version: "9.9.9", tarball: "", integrity: nil, shasum: nil, + dependencies: [:], optionalDependencies: optional, bin: [:]) +} - // Three claims per package: the portable build was installed, the loader REACHED it (which - // is what the flag buys), and what stopped it is shared memory. - let reached = text.contains("shared memory is not enabled") - print("\(item.name): wasi build installed=\(hasWasi), loader reached it=\(reached)") - if !hasWasi || !reached { - failures += 1 - print(" \(text.prefix(300))") - } +func packageVersion(_ root: URL, _ path: String) throws -> String { + let data = try Data(contentsOf: root.appendingPathComponent(path).appendingPathComponent("package.json")) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + return json?["version"] as? String ?? "?" } if failures == 0 { - print("NAPI WASI MATCH — both packages' WebAssembly builds install and their loaders reach " - + "them; what stops both is wasm threads, which JavaScriptCore does not enable") + print("NAPI WASI: 8 checks — unlisted binding derived and installed in lockstep, listed shape still wins, a 404 guess tolerated — MATCH") } else { - print("MISMATCH: \(failures) of \(cases.count) — the napi-rs wall moved, re-read where it is now") + print("NAPI WASI: \(failures) of 8 checks failed — MISMATCH") exit(1) } -try? FileManager.default.removeItem(at: base) From b1133b603a0881bd8aefaf7d31e885bd9d021adc Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 00:31:50 -0400 Subject: [PATCH 02/32] The listener leak behind vite's port storm, and canc twice to kill a program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- swift/Mouse/NodeEngine.swift | 40 ++++++++++++++++++++++++++++++ swift/Mouse/TerminalPrograms.swift | 17 +++++++++++++ swift/Mouse/TerminalSession.swift | 30 +++++++++++++++++++++- 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index 9f7b140..a34b9c8 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -17100,6 +17100,40 @@ final class NodeEngine: @unchecked Sendable { Server.prototype.listen = function() { const args = Array.prototype.slice.call(arguments); const callback = typeof args[args.length - 1] === 'function' ? args.pop() : null; + // Listening twice without an intervening close is a PROGRAMMING ERROR in node, and it + // throws ERR_SERVER_ALREADY_LISTEN. This used to allocate a second host socket and + // orphan the first — invisible on a machine whose process exits and frees the fd, fatal + // here where the engine outlives every program. vite's dev server calls listen again on + // EADDRINUSE and, through SvelteKit's warmup/restart, on a server already bound; each + // silent re-listen leaked a LISTENING socket, and a real device ran up thousands (3607 + // measured) before the port walk buried the terminal in "Port N is in use". Throwing is + // both correct and the fix: node's own EADDRINUSE retry closes first, and callers that + // relied on the silent second bind were leaking on node too. + // Listening again while a socket is already claimed used to allocate a SECOND host + // socket and orphan the first. On a machine whose process exits that is invisible; here + // the engine outlives every program, so each one leaked a LISTENING fd — a real device + // reached 3607 of them, and vite's port walk then reported thousands of ports "in use" + // (they were: by us) until the terminal was unusable. + // + // node throws ERR_SERVER_ALREADY_LISTEN here, and that was tried first. It is the wrong + // answer for this engine, because vite REACHES this path legitimately: its + // `createServerCloseFn` only closes a server it has seen emit 'listening' + // (`if (hasListened) server.close(…); else resolve()`), so a restart following a failed + // first bind re-listens a server it never closed. Throwing there killed a dev server + // that had already printed its URL. + // + // So the previous socket is RELEASED and the new listen proceeds. That fixes the leak — + // the only fd in flight is the one the caller asked for — 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 track of a socket, + // and here nothing is lost, because the socket is closed rather than orphaned. + if (this._sid) { + bridge.netDestroy(this._sid); + this._sid = 0; + this.listening = false; + this._address = null; + } + this._closing = false; let port = 0, host = '0.0.0.0', backlog = 511; // A path listens on a socket FILE. const unixPath = (args[0] && typeof args[0] === 'object' && args[0].path) ? String(args[0].path) @@ -17194,6 +17228,12 @@ final class NodeEngine: @unchecked Sendable { } case 'error': this.listening = false; + // The bind failed, so there is no host socket behind `_sid` — clear it, both to + // free the guard above (node's EADDRINUSE retry re-listens the same server) and + // because `_sid` is now the marker for "a socket is claimed", mirroring node's + // `_handle`. Leaving it set turned the legitimate retry into a false + // ERR_SERVER_ALREADY_LISTEN. + this._sid = null; this.emit('error', Object.assign(new Error(payload.message), { code: payload.code })); break; case 'close': diff --git a/swift/Mouse/TerminalPrograms.swift b/swift/Mouse/TerminalPrograms.swift index 391ec9a..a71060a 100644 --- a/swift/Mouse/TerminalPrograms.swift +++ b/swift/Mouse/TerminalPrograms.swift @@ -112,6 +112,11 @@ protocol TerminalProgram: AnyObject { func input(_ text: String) /// The visible grid changed size (rotation): adopt it and repaint. func resize(rows: Int, columns: Int) + /// The shell is taking the terminal back — stop, release everything, and exit. No veto: + /// this is `canc` pressed twice, the phone's close button, for a program that ignored ^C. + /// (`sv`'s closing screen kept a raw stdin listener alive after its work was done — the + /// ^C byte was the law's answer, and the byte went nowhere, forever.) + func terminate() } /// The kernel side of a running program: its stdout, its exit(2), and the screen geometry it @@ -230,6 +235,8 @@ final class PagerProgram: TerminalProgram { io?.write("\u{1b}[?25h\u{1b}[?1049l") io?.exit() } + + func terminate() { quit() } } /// `top` as a live program: repaints the same facts the one-shot builtin prints, on a @@ -292,6 +299,8 @@ final class TopProgram: TerminalProgram { io?.write("\u{1b}[?25h\u{1b}[?1049l") io?.exit() } + + func terminate() { quit() } } /// A Node program holding the terminal: the join between phase T (the screen) and phase G @@ -461,6 +470,14 @@ final class NodeProgram: TerminalProgram { engine.resizeTTY(rows: rows, columns: columns) } + /// Unconditional, unlike ^C: the engine's own kill switch — the same one `child.kill()` + /// uses for a spawned child — ends the loop on its next wakeup with exit 130, and + /// everything downstream is the ORDINARY exit path (result, io.exit, teardown), so + /// nothing leaks. A SIGINT handler that shrugged does not get consulted a second time. + func terminate() { + engine.terminate() + } + private func rawModeChanged(_ raw: Bool) { rawMode = raw // Raw keys mean the program is drawing its own UI (ink repaints in place, no alt diff --git a/swift/Mouse/TerminalSession.swift b/swift/Mouse/TerminalSession.swift index 8fa9cce..ecd0607 100644 --- a/swift/Mouse/TerminalSession.swift +++ b/swift/Mouse/TerminalSession.swift @@ -138,10 +138,37 @@ final class TerminalSession { /// Interrupt a running command (any keypress triggers this). Prints the classic ^C /// immediately; the command's own cancellation cleanup (ping's statistics line) follows /// as it winds down. + /// When `canc` last sent a program ^C — the arming half of the two-press kill. + private var interruptedAt: Date? + func interrupt() { if let program { // ^C is a keystroke to a program — it decides what to do (and the host stops it - // when it has no SIGINT handler of its own). + // when it has no SIGINT handler of its own). That is the law's answer, and it has + // a hole a real package fell into: a program that finished its work but left a + // raw-mode stdin listener alive (`sv`'s closing screen) reads the byte and does + // nothing, forever, and the terminal is held hostage. So a SECOND canc within ten + // seconds is the shell's answer: take the terminal back. A real terminal's close + // button, worn by canc on the second press. Ten, not two: the real cadence is + // tap, wait to see whether anything happened, tap again — and a window sized to + // a double-tap re-armed instead of killing, which was measured, not guessed. + if let at = interruptedAt, Date().timeIntervalSince(at) < 10 { + interruptedAt = nil + append("killed \(program.title)", .error) + program.terminate() + // Termination flows through the program's own exit path (io.exit → the + // session's cleanup), which is what keeps engines from leaking. A program too + // far gone to run even that still must not keep the screen: reclaim it + // directly after a beat, if the same program is somehow still installed. + let held = ObjectIdentifier(program as AnyObject) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + guard let self, let current = self.program, + ObjectIdentifier(current as AnyObject) == held else { return } + self.programExited() + } + return + } + interruptedAt = Date() program.input("\u{3}") return } @@ -249,6 +276,7 @@ final class TerminalSession { } program = nil programOnScreen = false + interruptedAt = nil parser.respond = nil // A crashed-out program must not strand the terminal on the alt screen. if screen.isAlternate { parser.feed("\u{1b}[?25h\u{1b}[?1049l") } From dfe85ba9be3157d282ca9c6bedc4317b7f6fad90 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 08:47:05 -0400 Subject: [PATCH 03/32] Brief: the vite restart loop's trigger, identified by stack capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 153 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 .claude/npm-dev-brief.md diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md new file mode 100644 index 0000000..bf631da --- /dev/null +++ b/.claude/npm-dev-brief.md @@ -0,0 +1,153 @@ +# Goal: `npm run dev` works for Node dev servers on iOS + +The loop prompt points here. Read this file every iteration. + +## The target, concretely + +In the simulator workspace `local__test-2` (a SvelteKit project created by `npx sv create`), +`npm run dev` must: + +1. start **once** — no restart loop, +2. not print `Failed to run dependency scan`, +3. print its URL, and +4. answer `curl http://localhost:/` **from the Mac** with the app's HTML — + not a 500, not a refused connection. + +The fix belongs in the ENGINE (`swift/`). Do not "fix" it by editing the user's project +or `node_modules`. Temporary instrumentation of `node_modules` is allowed for diagnosis +and MUST be reverted before the iteration ends. + +It must be general — React/Vite and any other node dev server, not a SvelteKit special case. + +## Root cause chain — measured, do not re-derive + +1. Vite prints `vite.config.ts changed, restarting server...` every ~2-3 seconds, forever. + The config's **mtime is constant** across restarts — the file genuinely never changes. +2. Each restart **aborts the in-flight esbuild dependency scan**. The captured scan error is + `[ERROR] The server is being restarted or closed. Request is outdated`, + `code: ERR_CLOSED_SERVER`, plugin `vite:dep-scan`, on @sveltejs/kit runtime files. + So "dependencies are not found" is a CONSEQUENCE of the restart loop, not a second bug. + Fix the loop and the scan should complete. +3. Vite's trigger is `handleHMRUpdate` in `node_modules/vite/dist/node/chunks/config.js`: + it restarts when the changed file equals `config.configFile`, is in + `config.configFileDependencies`, or is an env file. The printed name is exactly + `vite.config.ts`, so the file it saw IS the config path. + +## Already ruled out by device probes — do not repeat + +- esbuild core works: bundled a module, 38 bytes out. +- esbuild **plugin callbacks** over the pipe work (onResolve/onLoad): `PLUGIN OK bytes=61`. +- `fs.appendFileSync` creates a missing file correctly. +- A raw `fs.watch` on the config plus `fs.watch('.')` reported **zero** events while the + config was READ and while a temp file was written and deleted under + `node_modules/.vite-temp`. The naive "reading the config fires an event" theory is FALSE. +- The socket leak is already fixed (3607 leaked listeners, now bounded) — commit b1133b6. + +## ITERATION 1 FINDINGS — the theory in "Chase this first" below is now DEAD + +Two measurements, both on the simulator with the real project: + +1. `NodeWatch.emit` was instrumented with NSLog (every event, path + kind). During a FULL vite + restart loop it logged **ZERO events**. Our fs.watch is not firing at all — spurious events + are not the problem. +2. A chokidar probe using vite's own options (`chokidar.watch('.', { ignored: [...], + ignoreInitial: true })`, chokidar 4.0.3, which uses the callback `fs_watch`, not fsevents) + emitted **only `ready`**. It MISSED a control write of `canary.txt` directly into the watched + root. So watching is broken in the MISSING-events direction, end to end. + +So the restart trigger is NOT the file watcher, and there is a SECOND, separate bug: a new file +in a watched directory produces no event, which also means HMR cannot work. + +### THE TRIGGER IS NOW IDENTIFIED (iteration 1, measured) + +A stack capture at vite's config-change branch names the caller: + + handleHMRUpdate config.js:26028 + onHMRUpdate config.js:25635 + config.js:25654 <-- onFileAddUnlink + +Line 25654 is the last line of `onFileAddUnlink`, i.e. `onHMRUpdate(isUnlink ? 'delete' : +'create', file)`. So vite is being told the config was **ADDED or UNLINKED**, not changed. The +captured file is `/vite.config.ts`, isConfig=true. + +Combine that with the two facts above — our fs.watch emits nothing, and chokidar misses a real +new file — and the coherent theory is: + +**chokidar's INITIAL SCAN is leaking 'add' events past `ignoreInitial`.** Every vite restart +builds a NEW chokidar watcher, whose initial scan re-announces `vite.config.ts` as an `add`, +which vite treats as a config change, which restarts the server, which builds another watcher. +That is the whole loop, and it also explains why live changes are missed: only the scan works, +the watch itself delivers nothing. + +Likely engine cause to test: chokidar decides when the initial scan is over by counting pending +async operations and then emitting `ready`. If our fs/readdir async completion ordering lets +chokidar flip to ready BEFORE the scan has finished, everything the scan finds afterwards is +emitted as a genuine `add` rather than being suppressed. Look at how our `fs.readdir`/`stat` +callbacks are scheduled relative to `process.nextTick`/microtasks, and at chokidar's +`_emitReady`/`_readyCount` bookkeeping in node_modules/chokidar/esm/handler.js. + +Cheap decisive probe for next iteration: chokidar.watch with `ignoreInitial: true` on a +directory, log every `add` WITH a timestamp and log `ready` too. If adds arrive AFTER ready, +the theory is confirmed and the fix is in our async fs scheduling (or in how fs.watch registers). + +### Superseded next step +Instrument `handleHMRUpdate` in node_modules/vite/dist/node/chunks/config.js to capture +`new Error().stack` at the moment it decides the config changed, and write it to a file with the +module's own `fs` binding — `fs.writeFileSync('/x.json', ...)` at that site WORKS (proven: it is +how the ERR_CLOSED_SERVER scan error was captured; `require('fs')` there does NOT work). That +stack names the caller and ends the guessing about what triggers the restart. + +Then, separately, isolate the missing-events bug: raw `fs.watch('.', cb)` + create a file +directly in that directory, and see whether the callback fires. If it does not, fix directory +watching in NodeWatch.swift and gate it. + +### Still-uncommitted instrumentation +`swift/Mouse/NodeWatch.swift` has a TEMP-DIAGNOSTIC NSLog block in `emit(...)`. Remove it before +committing anything. + +## Chase this first (SUPERSEDED — kept for the record) + +What makes chokidar (riding our `fs.watch`) tell vite the config changed or was added? +Instrument OUR side — `swift/Mouse/NodeWatch.swift` and the `fs.watch` bridge — logging every +event with its path and kind. Do not instrument vite again; that path has been exhausted. + +FIRST SUSPECT, found by reading NodeWatch.swift at the end of the last session and NOT yet +tested: the per-file watch registers eventMask `[.write, .rename, .delete, .attrib, .extend]` +(NodeWatch.swift ~line 133), and the handler treats EVERYTHING that is not delete/rename as a +content `change` (~line 150). So a bare NOTE_ATTRIB — an attribute touch, no content change — +is reported to node as `change`. node's own fs.watch does emit on some attribute changes, but +chokidar's atime guard normally absorbs them, and here that guard fails open (see below), so a +single stray NOTE_ATTRIB on the config is enough to restart vite forever. Test whether dropping +`.attrib` (and possibly `.extend`) from the FILE mask, or distinguishing attrib-only events from +writes, stops the loop — then check it does not break verify/chokidar and the fs.watch gates. + +Two facts that matter: +- vite creates a **new chokidar watcher on every restart**, and chokidar emits `add` and + `unlink` as well as `change`; vite treats add/unlink of the config as a restart too. +- On this filesystem **atime == mtime** for the config, which makes chokidar's + "ignore atime-only events" guard (`at <= mt` -> emit) fail OPEN. So ANY watcher event on + that file becomes a restart. + +## Other known state + +- `tailwindcss()` was removed from `local__test-2`'s `vite.config.ts` during debugging to get + past a lightningcss crash. Either restore it and make it work, or state plainly that it is a + separate blocker: lightningcss ships only native optionalDependencies with no `wasm32-wasi`, + and a `lightningcss` -> `lightningcss-wasm` entry in `PackageManager.wasmSubstitutes` is the + candidate fix. +- Branch: `fix/rolldown-wasi-and-causes`. + +## Verification rule + +Verify on the booted simulator (iPhone 16 Pro, 58A6B442-292A-4610-9DE8-500E7E8EBC74) EVERY +iteration: build, install, launch, drive the terminal, screenshot, and curl the port from the +Mac. A green build is not evidence. + +Everything in AGENTS.md applies: gate what you fix, no leftover diagnostics, docs move with +behavior, commit at each boundary with the evidence in the message. + +## Stop condition + +On the simulator: `npm run dev` in `local__test-2` starts once, prints its URL, does NOT print +`Failed to run dependency scan`, and a curl from the Mac to that port returns the app's HTML — +with a screenshot and the curl output as evidence. Plus gates green and `swift/` committed. From adb7de3e884a69710ffb78e65f6be8e9e3d73463 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 08:55:59 -0400 Subject: [PATCH 04/32] Brief: the measurement trap that invalidated iteration 2, and the half-fixed leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index bf631da..e9823c5 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -58,6 +58,32 @@ Two measurements, both on the simulator with the real project: So the restart trigger is NOT the file watcher, and there is a SECOND, separate bug: a new file in a watched directory produces no event, which also means HMR cannot work. +## ITERATION 2 — A PROCESS TRAP THAT INVALIDATED MEASUREMENTS, AND A LEAK REFINEMENT + +READ THIS BEFORE MEASURING ANYTHING. + +1. **The dev server keeps running.** After `npm run dev`, the terminal is owned by the vite + PROGRAM. Anything typed afterwards goes to vite as KEYSTROKES, not to msh. Several + iteration-2 measurements were contaminated this way: `who.txt` kept showing output from a + previous run because the new `npm run dev` never actually started — the text went into the + running program. ALWAYS confirm the prompt reads `~ $` (kill with canc TWICE) before typing a + command, and confirm the command echoed on screen before trusting any result. + +2. **The listener leak is only HALF fixed.** b1133b6 releases the previous socket when the SAME + server object re-listens. But vite's restart builds a NEW http.Server each time, binds a new + port, and never closes the old one (its `createServerCloseFn` skips closing a server it never + saw emit 'listening'). So every restart still leaks one bound port: observed climbing + 5173 -> 5174 -> ... -> 6087 inside ONE app session. The leak is a SYMPTOM of the restart loop, + so fixing the loop mostly removes it — but a server that is garbage-collected without close + should still release its fd, and that is worth fixing on its own. + +3. Reading chokidar did not settle the trigger and cost a lot of cycles. Both chokidar copies + (v4.0.3 in node_modules, v3 inlined in vite's bundle) gate `add` on `initialAdd && + ignoreInitial`, and vite passes `ignoreInitial: true` (config.js:16779). The fsevents + `emitAdd` with its `forceAdd` bypass is NOT our path. So the leak of an `add` is still + unexplained by reading alone — MEASURE it: instrument chokidar's own `_emit` in node_modules + to append event+path+initialAdd to a file, with the dev server started cleanly per (1). + ### THE TRIGGER IS NOW IDENTIFIED (iteration 1, measured) A stack capture at vite's config-change branch names the caller: From 21eda195e574ac1d22fa2f21c3a12bc9bb01c35f Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 09:04:30 -0400 Subject: [PATCH 05/32] =?UTF-8?q?Brief:=20it=20is=20an=20unlink=20storm,?= =?UTF-8?q?=20not=20an=20add=20=E2=80=94=20every=20file=20reported=20delet?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index e9823c5..ed26f82 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -58,6 +58,43 @@ Two measurements, both on the simulator with the real project: So the restart trigger is NOT the file watcher, and there is a SECOND, separate bug: a new file in a watched directory produces no event, which also means HMR cannot work. +## ITERATION 3 — THE EVENT IS `unlink`, FOR EVERY FILE IN THE PROJECT + +Captured with a deep stack at vite's config-change branch, on a cleanly started server: + + === TYPE=delete FILE=/vite.config.ts + === TYPE=delete FILE=/static/robots.txt + === TYPE=delete FILE=/.gitignore + === TYPE=delete FILE=/.npmrc + ... + +chokidar is telling vite that EVERY FILE IN THE PROJECT WAS DELETED. Nothing was deleted. The +config's unlink is what restarts the server; the restart builds a new watcher, which shortly +declares everything deleted again. That is the loop, and it is an `unlink` storm, not an `add`. +(The iteration-1 guess of a leaked initial-scan `add` was WRONG — it is `delete`.) + +Directory reading is NOT the cause. Measured on device in that project: + - `fs.promises.readdir('.', { withFileTypes: true })` -> n=18, correct names, `isFile()` works. + - `readdirp('.', { depth: 0, type: 'all' })` -> n=18, every entry listed. +So the reads chokidar depends on are healthy. + +### Where the unlink must come from — instrument these two, in vite's INLINED chokidar +IMPORTANT: vite bundles its OWN chokidar inside `node_modules/vite/dist/node/chunks/config.js` +(see the `initialAdd && ignoreInitial` sites there). `node_modules/chokidar` (v4) is a DIFFERENT +copy used by other packages. Instrument the INLINED one — the unlink reaching vite comes from it. + +1. `_handleRead`'s directory diff: it builds `current` from a readdirp stream and then does + `previous.getChildren().filter(item => !current.has(item)).forEach(item => this.fsw._remove(...))`. + If `current` ends up EMPTY (stream yields nothing, or every entry is filtered out) while + `previous` holds all the files, every file is removed -> unlink for each. This is the prime + suspect and matches the symptom exactly. +2. `_handleFile`'s listener catch: `catch { this.fsw._remove(dirname, basename) }` when + `stat(file)` throws after a watch event. + +Decisive next step: add a logging line inside the inlined chokidar's `_remove` that appends +`item` plus `new Error().stack` (with `Error.stackTraceLimit = 60`) to a file using the module's +`fs` binding. That names which of the two paths fires, and the fix follows from it. + ## ITERATION 2 — A PROCESS TRAP THAT INVALIDATED MEASUREMENTS, AND A LEAK REFINEMENT READ THIS BEFORE MEASURING ANYTHING. From 6feb04e927abc3d8b1878e8ed483ac084d0976da Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 09:08:54 -0400 Subject: [PATCH 06/32] ROOT CAUSE: the project sits at / and chokidar deletes the world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index ed26f82..d49401a 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -58,6 +58,45 @@ Two measurements, both on the simulator with the real project: So the restart trigger is NOT the file watcher, and there is a SECOND, separate bug: a new file in a watched directory produces no event, which also means HMR cannot work. +## ROOT CAUSE FOUND (iteration 4) — the project sits at `/`, and chokidar cannot cope + +Instrumenting the INLINED chokidar's removal diff produced the answer in one run: + + REMOVE dir=/ item= currentSize=14 prevN=3 + +The item being removed is the EMPTY STRING, and `currentSize=14` proves the directory read is +healthy. The mechanism: + +- Our workspace is mounted so the project root is literally `/`. +- chokidar's `_handleDir` does `_getWatchedDir(dirname(dir)).add(basename(dir))`. +- For `dir === '/'`, POSIX says `dirname('/') === '/'` and `basename('/') === ''` (node agrees; + our path module is CORRECT here — this is not a path bug). +- So chokidar adds an empty-string child INTO THE ROOT'S OWN watched-dir record. +- On the next `_handleRead('/')`, `current` holds the real 14-15 entries and never contains `''`, + so the diff removes `''` -> `_remove('/', '')` -> that resolves back to the root itself and + tears down the whole tracked tree, emitting `unlink` for EVERY file, including vite.config.ts. +- vite sees the config unlinked -> restarts -> new watcher -> same thing ~3s later. The loop. + +On a real machine a project is never at `/`, so `dirname` returns a real parent and `basename` a +real name; this edge case never fires. It is our virtual-filesystem layout that triggers it, so +the fix belongs in swift/ and is general — it will fix watching for React/Vite/anything, and HMR +along with it. + +### The fix to implement next — decide between these two +1. **Give the workspace a non-root virtual path** (e.g. cwd `/project`, or a mount alias) so the + watched root has a real basename. The engine already has a `mounts` concept + (`NodeEngine(root:mounts:)`, `normalizeMountPrefix`, NodeEngine.swift ~193-201, 2476-2494) and + programs are launched with an explicit `cwd` (TerminalPrograms.swift ~341/397/441). Smallest + version: keep `/` working as it does today AND expose the same tree at a named path, then + launch node programs with that as cwd so tools resolve `root` to the named path. + Risk: user-visible paths change (msh prompt, Viewer, error traces) — check before committing. +2. **Make the root behave like a named directory to watchers only** — riskier and more magical; + prefer 1 unless 1 proves invasive. + +Whichever is chosen: gate it. A harness that watches a directory tree whose root is `/` and +asserts that NO unlink is emitted when nothing is deleted would have caught this, and there is +currently no such gate (verify/chokidar passes because it does not watch the virtual root). + ## ITERATION 3 — THE EVENT IS `unlink`, FOR EVERY FILE IN THE PROJECT Captured with a deep stack at vite's config-change branch, on a cleanly started server: From 7254ab645bf89155de2d05a036bfb65e44b317aa Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 09:11:22 -0400 Subject: [PATCH 07/32] Brief: scope the root-path fix, and the gate that was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index d49401a..7c8099c 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -82,7 +82,39 @@ real name; this edge case never fires. It is our virtual-filesystem layout that the fix belongs in swift/ and is general — it will fix watching for React/Vite/anything, and HMR along with it. -### The fix to implement next — decide between these two +### IMPLEMENTATION SCOPE, measured in iteration 5 + +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 `/`. vite takes that as `root`, chokidar watches +`/`, and the empty-basename bug fires. + +Changing the project's virtual root is NOT a one-line change: the whole engine is `/`-rooted — +the module 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 paths to the root. +Any rename shows up in all of those, which is why this needs a deliberate choice rather than a +quick edit. THE OWNER SHOULD PICK, because option A changes what users see. + + A. Root the workspace at a NAMED virtual path (e.g. `/project`) instead of `/`. + Correct and general — real projects are never at `/`, which is exactly why no other + platform hits this. Cost: user-visible paths change everywhere (prompt, traces, Viewer), + and every gate that asserts a `/`-rooted path needs updating. + B. Keep `/` as the workspace root, but make the path chokidar WATCHES have a real basename — + e.g. have `fs.realpathSync('/')` answer a named alias that also resolves, since chokidar + calls realpath on its watch target and uses the result for its bookkeeping. + Much smaller blast radius, but two names for one directory can desynchronise anything that + compares paths by string (vite computes root-relative paths constantly), so it must be + proven against the real dev server, not just a unit gate. + +Recommendation: try B behind the gate below FIRST, because it is cheap to test and reversible; +fall back to A if path comparisons break. + +### The gate this needs, whichever option wins +A harness that watches a tree whose root is the VIRTUAL ROOT with chokidar, changes nothing, and +asserts that NO `unlink` is emitted within a few seconds. That is the exact shape of the bug and +nothing in verify/ covers it today (verify/chokidar watches a subdirectory, which is why it has +always passed). + +### The original two options (superseded by the scope note above) 1. **Give the workspace a non-root virtual path** (e.g. cwd `/project`, or a mount alias) so the watched root has a real basename. The engine already has a `mounts` concept (`NodeEngine(root:mounts:)`, `normalizeMountPrefix`, NodeEngine.swift ~193-201, 2476-2494) and From dd733d4e5c0ec8cb445da545abdcdb05170590f4 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 09:13:07 -0400 Subject: [PATCH 08/32] Correct the brief: option B cannot work, and option A's exact call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index 7c8099c..ebd228d 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -105,8 +105,33 @@ quick edit. THE OWNER SHOULD PICK, because option A changes what users see. compares paths by string (vite computes root-relative paths constantly), so it must be proven against the real dev server, not just a unit gate. -Recommendation: try B behind the gate below FIRST, because it is cheap to test and reversible; -fall back to A if path comparisons break. +**CORRECTION (iteration 6): OPTION B IS IMPOSSIBLE. Do not attempt it.** It assumed chokidar +uses the realpath of the watch target for its bookkeeping. It does not. In vite's inlined +chokidar, `_handleDir` reads: + + const parentDir$1 = this.fsw._getWatchedDir(sysPath$2.dirname(dir)); // 13263 + const tracked = parentDir$1.has(sysPath$2.basename(dir)); // 13264 + parentDir$1.add(sysPath$2.basename(dir)); // 13268 + +`dir` — the path as passed in — drives dirname/basename; `realpath` is only consulted for the +`_symlinkPaths` check further down. So changing what realpath answers cannot remove the +empty-basename child. OPTION A IS THE ONLY FIX. + +### Option A, implementation plan (verified call sites) + +1. `swift/Mouse/Shell.swift:1474` and `:1485` — both launch node with `cwd: "/" + cwd`. Change to + a NAMED root, e.g. `/project`, joined with msh's relative cwd: + `"/project" + (cwd.isEmpty ? "" : "/" + cwd)`. +2. `swift/Mouse/NodeEngine.swift:2492 realURL(_:)` is the single virtual->real resolver. It + already walks `mounts` with prefix matching, so the named root can be added as an ALIAS that + resolves to the same workspace URL as `/`. Both `/project/src/a.js` and `/src/a.js` then work, + which keeps existing behaviour intact while giving vite a root with a real basename. +3. Because vite derives every path from its own `root`, it will consistently see `/project/...` + and never mix the two spellings. The thing to watch for in verification is anything that + hands a program a `/`-rooted path AFTER startup (the Viewer's openFile hook, msh's own path + clamping) — those still speak `/`, which is fine as long as nothing string-compares the two. +4. Stack traces will read `mouse:///project/...`. Cosmetic, but docs and any gate pinning + `mouse:///` paths must move with it. ### The gate this needs, whichever option wins A harness that watches a tree whose root is the VIRTUAL ROOT with chokidar, changes nothing, and From d27265e20292b5835b744b25107ecdf2087b0840 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 09:21:27 -0400 Subject: [PATCH 09/32] A project no longer lives at "/", and vite's restart storm ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- swift/Mouse/NodeEngine.swift | 30 ++++++++++++++++++++++++++++-- swift/Mouse/Shell.swift | 4 ++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index a34b9c8..010a161 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -198,10 +198,36 @@ final class NodeEngine: @unchecked Sendable { self.env = env self.shell = shell self.tty = tty - self.mounts = mounts.map { (normalizeMountPrefix($0.prefix), $0.url) } - .sorted { $0.prefix.count > $1.prefix.count } + // The workspace answers to a NAMED path as well as to "/", and a program is launched + // with the named one as its cwd. Both spellings resolve to the same directory, so + // nothing that already speaks "/" changes. + // + // This exists because a project rooted at "/" breaks file watching, and the failure is + // spectacular rather than subtle. chokidar records a directory under its parent: + // `_getWatchedDir(dirname(dir)).add(basename(dir))`. For dir == "/", POSIX says + // dirname is "/" and basename is "" — node agrees, our path module is right — so it + // files an EMPTY-NAMED child inside the root's own record. The next directory read + // never lists "", the diff calls it deleted, and removing "" resolves back to the root + // and tears down the whole tracked tree: an `unlink` for every file in the project. + // vite sees its config unlinked, restarts, builds a fresh watcher, and does it again + // about three seconds later — the restart storm, and with it the dependency scan dying + // as ERR_CLOSED_SERVER. No other platform hits this because no real project lives at + // the filesystem root. + // + // Caveat, recorded rather than hidden: a workspace containing a top-level directory + // with this name is shadowed by the alias, since the prefix is matched before the root + // fallback. `/project/x` would reach `/x`, and the real one needs + // `/project/project/x`. + var resolved: [(prefix: String, url: URL)] = + mounts.map { (prefix: normalizeMountPrefix($0.prefix), url: $0.url) } + resolved.append((prefix: Self.namedRoot, url: root)) + self.mounts = resolved.sorted { $0.prefix.count > $1.prefix.count } } + /// The workspace's named spelling — what a program gets as its cwd, so its own root has a + /// real basename. See the note in `init`. + static let namedRoot = "/project" + /// Attach the terminal after init — the host program can't hand closures over itself /// to its own initializer. Must happen before `run`. func attachTTY(_ tty: TTY) { diff --git a/swift/Mouse/Shell.swift b/swift/Mouse/Shell.swift index 3265395..a8ef770 100644 --- a/swift/Mouse/Shell.swift +++ b/swift/Mouse/Shell.swift @@ -1471,7 +1471,7 @@ final class MouseShell { let engine = NodeEngine(root: context.root, env: env, shell: bridge, mounts: runtimeMounts) let program = NodeProgram( title: title, source: source, path: path, argv: ["node"] + (isEval ? [] : [path]) + args, - cwd: "/" + cwd, engine: engine, + cwd: NodeEngine.namedRoot + (cwd.isEmpty ? "" : "/" + cwd), engine: engine, transcript: { line, isError in context.emit(Output(text: line, isError: isError)) }, clearTranscript: { context.clear() }, onExit: { context.reloadTree() }) @@ -1482,7 +1482,7 @@ final class MouseShell { defer { nodeDepth -= 1 } let engine = NodeEngine(root: context.root, env: env, shell: bridge, mounts: runtimeMounts) let result = await engine.run(source: source, path: path, argv: ["node"] + (isEval ? [] : [path]) + args, - cwd: "/" + cwd, stdin: stdin) + cwd: NodeEngine.namedRoot + (cwd.isEmpty ? "" : "/" + cwd), stdin: stdin) context.reloadTree() // scripts write files return IO(out: result.out, err: result.err, status: result.status) } From d2cd0241ec373f9940d86b395116a3619626189b Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 09:28:49 -0400 Subject: [PATCH 10/32] `import` is a legal method name, and vite's ModuleRunner uses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- swift/Mouse/NodeEngine.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index 010a161..e43f421 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -3265,7 +3265,15 @@ final class NodeEngine: @unchecked Sendable { while end < count, isIdent(bytes[end]) { end += 1 } let word = String(decoding: bytes[index.. Date: Tue, 11 Aug 2026 09:30:39 -0400 Subject: [PATCH 11/32] The pinned ESM divergence is the blocker: svelte needs live bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index ebd228d..2970e3d 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -1,5 +1,45 @@ # Goal: `npm run dev` works for Node dev servers on iOS +## CURRENT BLOCKER (iteration 9): the PINNED ESM divergence is no longer acceptable + +Fixed so far, in order, each verified on the simulator: the restart storm +(project rooted at `/`, commit d27265e), and `import` as a method name +(commit d2cd024). SSR now reaches svelte's compiler, which throws at +`1-parse/state/element.js:946`, i.e. `start: locator(start)`. + +`locator` is undefined, and the reason is our own known divergence: + + svelte/src/compiler/state.js:50 export let locator; // undefined at import time + svelte/src/compiler/state.js:59 locator = (i) => { … }; // assigned LATER + …/1-parse/state/element.js:17 import { locator } from '../../../state.js'; + +Real ESM named imports are LIVE bindings, so `locator` becomes the function. +Ours are a SNAPSHOT — the transpiler emits +`const locator = __esmBinding(ns, 'locator')` — so it is frozen at `undefined` +forever. This is precisely the divergence `verify/esmgrammar` records as +"1 pinned divergence (named imports are a snapshot; the namespace form is live)". +It is not a cosmetic gap: it breaks Svelte, and it will break any library that +exports a `let` and assigns it during initialisation. + +### What the fix requires +Named imports must read through the namespace at USE time, not at import time. +The transpiler currently emits one `const` per binding; making it live means +rewriting every REFERENCE to `ns.name`, which needs identifier-level rewriting +with scope awareness (shadowing, object keys, property access after a dot, +strings/comments already masked). That is a real piece of work in +`transpileESM` and it must be gated by extending verify/esmgrammar with a +live-binding case that FAILS today: + + // mod.js + export let value = 'before'; + export function set() { value = 'after'; } + // main.js + import { value, set } from './mod.js'; + set(); + console.log(value); // real node: 'after' ours today: 'before' + +Do that gate FIRST so the fix has something to turn green. + The loop prompt points here. Read this file every iteration. ## The target, concretely From 7c1a256cd54743fb288641d0fa5126414060fff8 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 20:46:23 -0400 Subject: [PATCH 12/32] Named imports are live bindings now, with a parse-failure escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- swift/Mouse/NodeEngine.swift | 185 +++++++++++++++++++++++++++++++++-- verify/esmgrammar/main.swift | 21 ++-- 2 files changed, 187 insertions(+), 19 deletions(-) diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index e43f421..8e2c8b5 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -2911,6 +2911,7 @@ final class NodeEngine: @unchecked Sendable { } registerSourceMap(id: id, source: source) let esm = isESModule(id: id, source: source) + let rawSource = source if esm { source = transpileCached(source) } else if source.contains("import") { @@ -2920,7 +2921,22 @@ final class NodeEngine: @unchecked Sendable { } // ESM evaluates under an ASYNC wrapper (its imports may await a top-level-await // dependency); CJS stays a plain sync function. - guard let function = wrapModule(source, async: esm, sourceURL: id) else { + var function = wrapModule(source, async: esm, sourceURL: id) + if function == nil, esm { + // Live bindings are a REFERENCE REWRITE, and a rewrite over third-party code is + // never provably safe: minified bundles put an imported name in places a scanner + // cannot always classify (a class field, a template substitution, an object + // shorthand). Any miss shows up as a SyntaxError, which is exactly the signal + // needed — so the module is transpiled again with bindings left as copies, which + // is what this engine did for its whole life before. Correct where it can be, + // never worse than before where it cannot. + let snapshot = transpileCached(rawSource, liveBindings: false) + if let retried = wrapModule(snapshot, async: true, sourceURL: id) { + source = snapshot + function = retried + } + } + guard let function else { return throwInJS("Cannot parse '\(id)'" + (lastParseProblem.map { ": " + $0 } ?? "")) } let module = context.evaluateScript("({exports: {}})")! @@ -3086,16 +3102,17 @@ final class NodeEngine: @unchecked Sendable { return directory }() - private func transpileCached(_ source: String) -> String { + private func transpileCached(_ source: String, liveBindings: Bool = true) -> String { guard source.utf8.count >= Self.transpileCacheThreshold, let directory = Self.transpileCacheDirectory else { - return Self.transpileESM(source) + return Self.transpileESM(source, liveBindings: liveBindings) } let digest = SHA256.hash(data: Data(source.utf8)) - let name = digest.map { String(format: "%02x", $0) }.joined() + "-v\(Self.transpilerVersion).cjs" + let name = digest.map { String(format: "%02x", $0) }.joined() + + (liveBindings ? "" : "-snap") + "-v\(Self.transpilerVersion).cjs" let file = directory.appendingPathComponent(name) if let cached = try? String(contentsOf: file, encoding: .utf8) { return cached } - let transpiled = Self.transpileESM(source) + let transpiled = Self.transpileESM(source, liveBindings: liveBindings) try? transpiled.write(to: file, atomically: true, encoding: .utf8) return transpiled } @@ -3316,7 +3333,7 @@ final class NodeEngine: @unchecked Sendable { return rewriteImportForms(source, meta: false).text } - static func transpileESM(_ source: String) -> String { + static func transpileESM(_ source: String, liveBindings: Bool = true) -> String { var text = source // Emitted BEFORE the body: function declarations are hoisted, so a cycle reaching back // into this module finds them, and every export reads through a getter rather than @@ -3419,6 +3436,35 @@ final class NodeEngine: @unchecked Sendable { .joined(separator: ", ") } + // Named imports promoted to live bindings: alias -> `.`, applied to every + // reference in a final pass. See the note at the emission site. + var live: [(alias: String, temp: String, source: String)] = [] + // The whole source as code-only text, computed once: declarations cannot be introduced by + // our own rewrites, so the shadowing question is answered against the ORIGINAL. + let shadowMask = rewriteImportForms(source, meta: false, mask: true).text + /// Does this module bind `name` itself anywhere — a declaration, a parameter, a catch, a + /// destructure? Then the name is not unambiguously the import, and it is left as a copy. + /// Deliberately over-eager: a false "yes" costs live-ness, a false "no" corrupts code. + func shadows(_ name: String) -> Bool { + let n = NSRegularExpression.escapedPattern(for: name) + let patterns = [ + "\\b(?:let|const|var|function|class)\\s+\\Q\(name)\\E\\b", + "\\bcatch\\s*\\(\\s*\\Q\(name)\\E\\b", + "\\bfunction\\b[^(){]*\\([^)]*\\b" + n + "\\b[^)]*\\)", + "\\([^()]*\\b" + n + "\\b[^()]*\\)\\s*=>", + "\\b" + n + "\\s*=>", + "[\\{,]\\s*\\b" + n + "\\b\\s*[,\\}][^=;\\n]*=[^=]", + ] + for pattern in patterns { + if let regex = try? NSRegularExpression(pattern: pattern), + regex.firstMatch(in: shadowMask, + range: NSRange(location: 0, length: (shadowMask as NSString).length)) != nil { + return true + } + } + return false + } + // Every import site awaits ONLY a genuinely-pending (top-level-await) dependency: // `if (x instanceof Promise) x = await x`. A fully-sync dependency evaluates without // suspension, so sync modules stay sync under the async wrapper; a TLA dependency @@ -3442,7 +3488,21 @@ final class NodeEngine: @unchecked Sendable { // closed. Destructuring reads it at import time and turns a legal cycle into a // ReferenceError, which is what execa's send.js ↔ strict.js pair hit. for binding in bindings(named) { - lines.append("const \(binding.alias) = __esmBinding(\(temp), '\(binding.source)');") + // A named import is a LIVE BINDING in real ESM: the importer sees a value the + // exporter assigns later. A `const` copies it once, and svelte's compiler is + // the case that made this matter — `export let locator;` is undefined at + // import time and filled in during init, so a copy stays undefined forever + // and the parser dies calling it. + // + // Live means reading through the namespace where the name is USED, which is a + // reference rewrite. It is only safe where the name cannot be something else, + // so `shadows` refuses whenever the module also DECLARES that name; those + // fall back to the copy, which is exactly today's behaviour and no worse. + if !liveBindings || shadows(binding.alias) { + lines.append("const \(binding.alias) = __esmBinding(\(temp), '\(binding.source)');") + } else { + live.append((alias: binding.alias, temp: temp, source: binding.source)) + } } } if let namespace = group(match, 3, ns) { @@ -3610,12 +3670,121 @@ final class NodeEngine: @unchecked Sendable { // assignment put a phantom key into `Object.keys(ns)`, into a spread of it, and into // anything that walks a module's exports. Defined this way the interop check still // sees it and nothing that ENUMERATES does. + // Live bindings, applied last: every remaining reference to a promoted alias becomes a + // read through the namespace, so a value the exporter assigns later is seen. Only names + // `shadows` cleared get here, so a bare occurrence is the import and nothing else. + // + // Three shapes have to be told apart, and the mask makes that safe by blanking strings, + // comments and regexes first: + // `{ locator }` object shorthand -> `{ locator: __esm1.locator }` (a bare + // `__esm1.locator` there is a syntax error) + // `{ locator: … }` a KEY, not a read -> left alone + // `locator` everything else -> `__esm1.locator` + // `.locator` is skipped by the preceding-character test, which is what already kept + // `runner.import` intact. + var body = prologue.joined(separator: " ") + " " + text + "\n;" + epilogue.joined(separator: " ") + if !live.isEmpty { + let mask = rewriteImportForms(body, meta: false, mask: true).text + let ns = body as NSString + let maskNS = mask as NSString + if maskNS.length == ns.length { + var edits: [(range: NSRange, replacement: String)] = [] + for binding in live { + let pattern = "\\b" + NSRegularExpression.escapedPattern(for: binding.alias) + "\\b" + guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } + for match in regex.matches(in: mask, range: NSRange(location: 0, length: maskNS.length)) { + let at = match.range.location + // Only code. The mask blanks comments, templates and regexes, but it + // keeps QUOTED strings verbatim on purpose — the import patterns read + // specifiers out of them — so a quoted `send` passes the mask test and + // has to be excluded here. Without this, `from './send.mjs'` became + // `from './__esm1.send.mjs'` and the module vanished. + guard maskNS.substring(with: match.range) == ns.substring(with: match.range) else { continue } + var quote: unichar = 0 + var lineStart = at + while lineStart > 0, maskNS.character(at: lineStart - 1) != 0x0a { lineStart -= 1 } + var probe = lineStart + while probe < at { + let c = maskNS.character(at: probe) + if c == 0x5c { probe += 2; continue } // escape + if quote == 0, c == 0x27 || c == 0x22 { quote = c } + else if quote != 0, c == quote { quote = 0 } + probe += 1 + } + if quote != 0 { continue } // inside a string + var before = at - 1 + while before >= 0, maskNS.character(at: before) == 0x20 || maskNS.character(at: before) == 0x09 { + before -= 1 + } + if before >= 0 { + let previous = maskNS.character(at: before) + if previous == 0x2e || previous == 0x23 { continue } // .name #name + } + var after = at + match.range.length + while after < maskNS.length, + maskNS.character(at: after) == 0x20 || maskNS.character(at: after) == 0x09 { after += 1 } + let next: unichar = after < maskNS.length ? maskNS.character(at: after) : 0 + if next == 0x3a { continue } // `name:` is a key + let read = "\(binding.temp).\(binding.source)" + // Object shorthand needs the ENCLOSING bracket, not the preceding + // character: `f(a, named)` and `{ a, named }` both sit after a comma, + // and rewriting the first to `named: …` is a syntax error inside a call + // — which is exactly what broke import-forms. Walk back over balanced + // pairs to whatever bracket actually contains this name. + var enclosing: unichar = 0 + var scan = before + var depth = 0 + while scan >= 0 { + let c = maskNS.character(at: scan) + if c == 0x29 || c == 0x5d || c == 0x7d { depth += 1 } + else if c == 0x28 || c == 0x5b || c == 0x7b { + if depth == 0 { enclosing = c; break } + depth -= 1 + } else if depth == 0 && (c == 0x3b) { break } + scan -= 1 + } + // `${ name }` is a template SUBSTITUTION, not an object: its brace is + // preceded by `$`, and treating it as shorthand emitted `name: …` inside + // a template expression — which is how vite's own cli.js stopped parsing. + var braceIsObject = enclosing == 0x7b + if braceIsObject, scan > 0, maskNS.character(at: scan - 1) == 0x24 { + braceIsObject = false + } + // Shorthand needs BOTH: the enclosing bracket is an object brace, and + // the name sits at the start of a property — directly after `{` or `,`. + // Without the second test, `{ key: imported }` matched (the scan walks + // back past the colon to the brace) and produced + // `{ key: imported: ns.imported }`, which is how fdir stopped parsing. + let previousChar: unichar = before >= 0 ? maskNS.character(at: before) : 0 + let atPropertyStart = previousChar == 0x7b || previousChar == 0x2c + let shorthand = braceIsObject && atPropertyStart && (next == 0x2c || next == 0x7d) + edits.append((match.range, shorthand ? "\(binding.alias): \(read)" : read)) + } + } + if !edits.isEmpty { + var result = "" + var cursor = 0 + for edit in edits.sorted(by: { $0.range.location < $1.range.location }) { + guard edit.range.location >= cursor else { continue } + result += ns.substring(with: NSRange(location: cursor, + length: edit.range.location - cursor)) + result += edit.replacement + cursor = edit.range.location + edit.range.length + } + if cursor < ns.length { + result += ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)) + } + body = result + } + } + } + // Everything before the body goes on ONE line and everything after it on the last, for // the same reason the wrapper does: a module's line 5 has to still be line 5. The // prologue was four lines of live-export getters, so an ESM stack pointed four lines // past the throw. return "Object.defineProperty(module.exports, '__esModule', { value: true }); try { " - + prologue.joined(separator: " ") + " " + text + "\n;" + epilogue.joined(separator: " ") + + body + " ;module.__esmDone = true; } catch (__esmThrown) { module.__esmError = __esmThrown; throw __esmThrown; }" } diff --git a/verify/esmgrammar/main.swift b/verify/esmgrammar/main.swift index a276b45..3388fb1 100644 --- a/verify/esmgrammar/main.swift +++ b/verify/esmgrammar/main.swift @@ -126,15 +126,14 @@ let cases: [Case] = [ import { slow } from './slow.mjs'; console.log(data.label, slow); """]), - // A binding, not a copy: the importer must see a later mutation. This is the one place a - // CommonJS rewrite can silently diverge, because assigning exports at end-of-module makes a - // SNAPSHOT of whatever the value was then. - // The exporting side is live: `ns.count` reads through a getter defined before the body, - // so a later mutation shows. The IMPORTING side is not, and cannot be without a parser: a - // transpile to CommonJS binds `count` as a local, and making it live would mean rewriting - // every reference to it, which needs real scope analysis to avoid capturing a shadowing - // parameter of the same name. Pinned rather than hidden — `import * as ns` is the form that - // behaves, and this says exactly how far the divergence goes. + // A binding, not a copy: the importer must see a later mutation. Both sides are live now. + // The exporting side always was — `ns.count` reads through a getter defined before the body. + // The IMPORTING side was a snapshot for a long time, pinned here as an accepted divergence + // on the grounds that fixing it needed a parser. It did not need a parser; it needed the + // reference rewrite in `transpileESM`, which promotes a named import to a read through the + // namespace wherever the module does not itself bind that name. What forced the issue was + // svelte: its compiler does `export let locator;` and fills it in during init, so a snapshot + // stayed undefined and every SSR render died calling it. Case(name: "live-bindings", files: [ "counter.mjs": """ export let count = 0; @@ -149,7 +148,7 @@ let cases: [Case] = [ bump(); bump(); hit(); console.log('after', count, ns.count, box.hits); """], - pinned: (ours: "before 0 0 0\nafter 0 2 1\n", real: "before 0 0 0\nafter 2 2 1\n")), + ), Case(name: "string-names-and-tdz", files: [ "dep.mjs": """ const value = 'quoted name'; @@ -280,7 +279,7 @@ for item in cases { if failures == 0 { let pinnedCount = cases.filter { $0.pinned != nil }.count print("ESM GRAMMAR MATCH — \(cases.count - pinnedCount) module shapes behave as real node's, " - + "\(pinnedCount) pinned divergence (named imports are a snapshot; the namespace form is live)") + + "\(pinnedCount) pinned divergences") } else { print("FAIL: \(failures) of \(cases.count) module shapes differ") exit(1) From e3078440a08b782d21f9c5023b0ce8d774815f3d Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 20:56:42 -0400 Subject: [PATCH 13/32] Live bindings made fail-safe, and a response body that arrived as its own digits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "" — 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 --- swift/Mouse/NodeEngine.swift | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index 8e2c8b5..f753a63 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -3498,9 +3498,14 @@ final class NodeEngine: @unchecked Sendable { // reference rewrite. It is only safe where the name cannot be something else, // so `shadows` refuses whenever the module also DECLARES that name; those // fall back to the copy, which is exactly today's behaviour and no worse. - if !liveBindings || shadows(binding.alias) { - lines.append("const \(binding.alias) = __esmBinding(\(temp), '\(binding.source)');") - } else { + // The declaration is ALWAYS emitted, even when the name is promoted to a + // live binding. The rewrite below skips any occurrence it cannot classify, + // and without a declaration to fall back on such a name would simply not + // exist — svelte's a11y constants died as "Can't find variable: AXObjects" + // for exactly that reason. With the const still there, a missed reference + // reads the old snapshot: the previous behaviour, not a crash. + lines.append("const \(binding.alias) = __esmBinding(\(temp), '\(binding.source)');") + if liveBindings && !shadows(binding.alias) { live.append((alias: binding.alias, temp: temp, source: binding.source)) } } @@ -3720,6 +3725,23 @@ final class NodeEngine: @unchecked Sendable { let previous = maskNS.character(at: before) if previous == 0x2e || previous == 0x23 { continue } // .name #name } + // A DECLARATION of the name is never a read — including the + // `const x = __esmBinding(…)` emitted just above, which would otherwise + // become `const __esm1.x = …`. + var keywordEnd = before + 1 + var keywordStart = before + while keywordStart >= 0, maskNS.character(at: keywordStart) != 0x20, + maskNS.character(at: keywordStart) != 0x09, + maskNS.character(at: keywordStart) != 0x0a, + maskNS.character(at: keywordStart) != 0x3b, + maskNS.character(at: keywordStart) != 0x7b { keywordStart -= 1 } + keywordStart += 1 + if keywordEnd > keywordStart { + let keyword = maskNS.substring(with: NSRange(location: keywordStart, + length: keywordEnd - keywordStart)) + if keyword == "const" || keyword == "let" || keyword == "var" + || keyword == "function" || keyword == "class" { continue } + } var after = at + match.range.length while after < maskNS.length, maskNS.character(at: after) == 0x20 || maskNS.character(at: after) == 0x09 { after += 1 } @@ -5752,6 +5774,11 @@ final class NodeEngine: @unchecked Sendable { return Buffer.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); } if (value instanceof ArrayBuffer) return Buffer.from(new Uint8Array(value)); + // An ARRAY of numbers is bytes, which is what `Buffer.from([…])` means in node. Falling + // through to String() turned one into its own decimal listing: a SvelteKit error page + // arrived over the wire as "60,33,100,111,99,116,..." — the source text of + // `` rendered as comma-separated character codes. + if (Array.isArray(value)) return Buffer.from(value); return Buffer.from(String(value), encoding && encoding !== 'buffer' ? encoding : 'utf8'); }; // TypeScript, compiled by the project's OWN typescript package — the ts-node model, and From 63287447e664e4393d7e791b3974b433fab3bd45 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 21:09:20 -0400 Subject: [PATCH 14/32] A Uint8Array body is bytes, not its own decimal listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `curl` was answering with "60,33,100,111,99,116,121,112,101,32,104,116,109,108, 62,..." — which decodes to "". 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('

hi

')).text() before: "60,112,62,104,105,60,47,112,62" after: "

hi

" 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 `` 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 --- swift/Mouse/NodeEngine.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index f753a63..bbb54d6 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -12299,8 +12299,12 @@ final class NodeEngine: @unchecked Sendable { if (hasBody && (this.method === 'GET' || this.method === 'HEAD')) { throw new TypeError('Request with GET/HEAD method cannot have body.'); } - defineBody(this, !hasBody ? Buffer.alloc(0) - : (Buffer.isBuffer(body) ? body : Buffer.from(String(body))), hasBody); + // __toBytes, not String(): a Uint8Array is BYTES, and stringifying one yields its + // decimal listing. That is literally what went over the wire — a rendered page + // arrived as "60,33,100,111,99,..." instead of "" — because + // `new Response(new TextEncoder().encode(html))` is how a framework returns a page, + // and a Uint8Array is not a Buffer. + defineBody(this, !hasBody ? Buffer.alloc(0) : __toBytes(body), hasBody); } clone() { if (this.bodyUsed) throw new TypeError('Body is unusable: Body has already been read'); @@ -12330,8 +12334,12 @@ final class NodeEngine: @unchecked Sendable { // A ReadableStream body stays a stream — that is what makes a streaming response // readable as it arrives instead of after it finishes. if (body && typeof body.getReader === 'function') { defineStreamBody(this, body); return; } - defineBody(this, !hasBody ? Buffer.alloc(0) - : (Buffer.isBuffer(body) ? body : Buffer.from(String(body))), hasBody); + // __toBytes, not String(): a Uint8Array is BYTES, and stringifying one yields its + // decimal listing. That is literally what went over the wire — a rendered page + // arrived as "60,33,100,111,99,..." instead of "" — because + // `new Response(new TextEncoder().encode(html))` is how a framework returns a page, + // and a Uint8Array is not a Buffer. + defineBody(this, !hasBody ? Buffer.alloc(0) : __toBytes(body), hasBody); } get ok() { return this.status >= 200 && this.status < 300; } clone() { From e45bb6e5a2646bdb223474e423f31ef19bf71c37 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 21:39:35 -0400 Subject: [PATCH 15/32] A URL in an error message turned svelte's compiler half-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- swift/Mouse/NodeEngine.swift | 59 +++++++++++++++++++++++++++++++----- verify/esmgrammar/main.swift | 43 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index bbb54d6..777b310 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -3085,7 +3085,7 @@ final class NodeEngine: @unchecked Sendable { /// are assigned at EOF (function/class hoist; const/let are bound by then). /// Bumped whenever `transpileESM` changes: the cache is content-addressed by SOURCE, so /// without this a stale rewrite would be reused after an engine update. - private static let transpilerVersion = 7 + private static let transpilerVersion = 8 /// Transpiling a big bundle is the dominant cost of launching a bundled CLI — /// claude-code's 9.3 MB takes ~1.85 s of the ~2.4 s load, every launch. The result is a @@ -3333,6 +3333,32 @@ final class NodeEngine: @unchecked Sendable { return rewriteImportForms(source, meta: false).text } + /// Blanks the INSIDE of every quoted string, keeping the quotes, every other character and + /// the total length. Only the shadow test uses this: it asks whether a module declares a + /// name, and prose can hold any word. + static func blankQuotedStrings(_ text: String) -> String { + let ns = text as NSString + var chars = [unichar](repeating: 0, count: ns.length) + ns.getCharacters(&chars) + var i = 0 + while i < chars.count { + let quote = chars[i] + guard quote == 0x27 || quote == 0x22 else { i += 1; continue } + var j = i + 1 + // A quoted string ends at its quote or at the line end — an unterminated quote is an + // apostrophe in a comment the mask already blanked, and must not eat the rest of the file. + while j < chars.count, chars[j] != quote, chars[j] != 0x0a { + j += chars[j] == 0x5c ? 2 : 1 + } + let end = min(j, chars.count) + if end > i + 1 { + for k in (i + 1).. String { var text = source // Emitted BEFORE the body: function declarations are hoisted, so a cycle reaching back @@ -3441,7 +3467,17 @@ final class NodeEngine: @unchecked Sendable { var live: [(alias: String, temp: String, source: String)] = [] // The whole source as code-only text, computed once: declarations cannot be introduced by // our own rewrites, so the shadowing question is answered against the ORIGINAL. - let shadowMask = rewriteImportForms(source, meta: false, mask: true).text + // + // The general mask keeps QUOTED STRINGS verbatim, because the import patterns read + // specifiers out of them. The shadow test never wants a string's contents, and reading + // them costs correctness: svelte's compiler throws an error whose text is a + // `https://svelte.dev/...` link, and the `dev` in that URL sat inside the enclosing + // call's parentheses, so the parameter-list pattern below decided the module bound its + // own `dev`. It does not — `dev` is `export let dev` in the compiler's state module, + // assigned per compile — and left as a copy it read `false` forever. Components then + // compiled HALF in dev mode: `push_element` emitted, the `FILENAME` it reads not, and + // every SvelteKit page died in svelte's own renderer. + let shadowMask = blankQuotedStrings(rewriteImportForms(source, meta: false, mask: true).text) /// Does this module bind `name` itself anywhere — a declaration, a parameter, a catch, a /// destructure? Then the name is not unambiguously the import, and it is left as a copy. /// Deliberately over-eager: a false "yes" costs live-ness, a false "no" corrupts code. @@ -3450,7 +3486,10 @@ final class NodeEngine: @unchecked Sendable { let patterns = [ "\\b(?:let|const|var|function|class)\\s+\\Q\(name)\\E\\b", "\\bcatch\\s*\\(\\s*\\Q\(name)\\E\\b", - "\\bfunction\\b[^(){]*\\([^)]*\\b" + n + "\\b[^)]*\\)", + // `b.function(…)` is a CALL to a method named function, never a declaration, and + // its arguments are not parameters. Without the lookbehind, any name appearing + // anywhere inside such a call read as a shadow. + "(?", "\\b" + n + "\\s*=>", "[\\{,]\\s*\\b" + n + "\\b\\s*[,\\}][^=;\\n]*=[^=]", @@ -3717,10 +3756,15 @@ final class NodeEngine: @unchecked Sendable { probe += 1 } if quote != 0 { continue } // inside a string + // NEWLINES are whitespace here. A property list is written one per line, + // so `{\n a,\n b\n}` put a newline between `,` and the name and every + // entry failed the property-start test below — the shorthand became + // `{ a, ns.b }`, which does not parse, and the whole module silently fell + // back to snapshot bindings. svelte's compiler keeps its visitors in + // exactly that shape. + func isSpace(_ c: unichar) -> Bool { c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d } var before = at - 1 - while before >= 0, maskNS.character(at: before) == 0x20 || maskNS.character(at: before) == 0x09 { - before -= 1 - } + while before >= 0, isSpace(maskNS.character(at: before)) { before -= 1 } if before >= 0 { let previous = maskNS.character(at: before) if previous == 0x2e || previous == 0x23 { continue } // .name #name @@ -3743,8 +3787,7 @@ final class NodeEngine: @unchecked Sendable { || keyword == "function" || keyword == "class" { continue } } var after = at + match.range.length - while after < maskNS.length, - maskNS.character(at: after) == 0x20 || maskNS.character(at: after) == 0x09 { after += 1 } + while after < maskNS.length, isSpace(maskNS.character(at: after)) { after += 1 } let next: unichar = after < maskNS.length ? maskNS.character(at: after) : 0 if next == 0x3a { continue } // `name:` is a key let read = "\(binding.temp).\(binding.source)" diff --git a/verify/esmgrammar/main.swift b/verify/esmgrammar/main.swift index 3388fb1..6ab5326 100644 --- a/verify/esmgrammar/main.swift +++ b/verify/esmgrammar/main.swift @@ -212,6 +212,49 @@ let cases: [Case] = [ console.log(send('a')); console.log(resend('b')); """]), + // Two ways the live-binding rewrite read the source wrong, both found in svelte's compiler + // and both silent: the module kept working, it just saw the value the binding held at import + // time. `dev` here is the real shape — `export let dev`, assigned per compile — and the URL + // in the thrown message is the real reason it stopped being live: `svelte.dev` sat inside the + // enclosing call's parentheses, so `dev` read as that call's parameter. The visitor table is + // the other one: shorthand properties written one per line, where the comma and the name are + // on different lines. + Case(name: "live-through-prose-and-tables", files: [ + "state.mjs": """ + export let dev; + export function set_dev(value) { dev = value; } + """, + "visitors.mjs": """ + export const AssignmentExpression = 'assign'; + export const AwaitExpression = 'await'; + """, + "transform.mjs": """ + import { dev } from './state.mjs'; + import { AssignmentExpression, AwaitExpression } from './visitors.mjs'; + const b = { + function(name, body) { + if (name === null) { + throw new Error('no longer valid. See https://svelte.dev/docs/svelte/v5-migration-guide for more'); + } + return name + body; + }, + }; + export const visitors = { + _: 'scope', + AssignmentExpression, + AwaitExpression + }; + export function transform() { + return [dev, b.function('n', 'b'), visitors.AssignmentExpression].join(','); + } + """, + "main.mjs": """ + import { set_dev } from './state.mjs'; + import { transform } from './transform.mjs'; + console.log(transform()); + set_dev(true); + console.log(transform()); + """]), Case(name: "code-in-strings", files: [ "main.mjs": """ const emitted = [ From 7f885edff1cb8ec51ae8e991286a39c69a2bb36b Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 21:45:19 -0400 Subject: [PATCH 16/32] The dependency scan was never broken twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 --- .claude/npm-dev-brief.md | 38 +++++++++++++++++++++++++++++++++++++- STATUS.md | 2 +- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index 2970e3d..6e0feac 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -1,6 +1,42 @@ # Goal: `npm run dev` works for Node dev servers on iOS -## CURRENT BLOCKER (iteration 9): the PINNED ESM divergence is no longer acceptable +## MET — SvelteKit, on the simulator, curled from the Mac + + VITE v7.3.6 ready in 19343 ms started once, no restart loop + curl http://localhost:5173/ HTTP 200, 33105 bytes, the rendered app + node_modules/.vite/deps 20 dependencies pre-bundled + +The scan no longer fails. The one remaining sighting of "Failed to run +dependency scan" came from a harness that called `server.close()` while the +scan was still in flight — vite's own `ERR_CLOSED_SERVER`, correctly reported. +Left open, the scan completes and writes its metadata. + +The five fixes, in the order they were found, each verified on the simulator: +the restart storm (a project rooted at `/`, so chokidar filed an empty-named +child in the root's own record and tore the tree down every three seconds — +launch cwd is now `/project`), `import` as a method name, ESM named imports as +live bindings rather than snapshots, `Request`/`Response` bodies built from +typed arrays instead of `String(body)`, and the two misreadings of source text +that left svelte's `dev` a snapshot after all — prose inside a string read as +code, and shorthand properties written one per line. + +### Still open +- `error.stack` on JSC carries no `Name: message` header the way V8's does. + Every framework that logs `err.stack` — SvelteKit's `format_server_error`, + vite's optimizer — therefore prints frames with no message, which cost most + of the diagnosis time in this loop and costs a user the same. `stack` is an + own writable data property on both JS-created and engine-thrown errors, so + creation cannot be hooked; the honest fix is wrapping the error constructors, + which covers everything thrown by JS and leaves engine-thrown TypeErrors to + `util.inspect`, which already rebuilds the header. +- Nothing gates a watcher rooted at the program root asserting no `unlink` when + nothing changed. `verify/chokidar` watches a subdirectory, which is why the + restart storm was invisible to the suite. +- `tailwindcss()` is still removed from `local__test-2/vite.config.ts` from + debugging. Restoring it needs `lightningcss` → `lightningcss-wasm` in + `PackageManager.wasmSubstitutes`. + +## History — blocker at iteration 9: the PINNED ESM divergence Fixed so far, in order, each verified on the simulator: the restart storm (project rooted at `/`, commit d27265e), and `import` as a method name diff --git a/STATUS.md b/STATUS.md index ed8a512..534e863 100644 --- a/STATUS.md +++ b/STATUS.md @@ -17,7 +17,7 @@ numbers some of the same ground differently; the mapping is noted per row. | **A** — shell language | msh's POSIX-subset grammar: control flow, `$()`, functions, redirects | **Done** | `swift/Mouse/ShellLanguage.swift`. Gated against real `/bin/sh` on the 25-script corpus (`verify/shell`) | | **F** — package manager | Real npm registry, semver, integrity, lockfiles; `npm run`/`create`/`npx`; native-binary → wasm-build substitution | **Done** | `swift/Mouse/PackageManager.swift`. Gated: semver corpus vs pnpm, `verify/pkg`, `firstrun` (scaffold→install→dev end to end) | | **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. ~88-gate suite green at 953dd98 (`verify/`), fixtures byte-identical to real node v22 | -| **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). Remaining piece is the Preview surface (phase C) | +| **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). A real SvelteKit project runs `npm run dev` on the device: starts once, pre-bundles its 20 dependencies, and answers a curl from the Mac with HTTP 200 and the rendered 33 kB page. Remaining piece is the Preview surface (phase C) | | **B** — WebView JIT | Move JS/wasm execution into WKWebView for JIT speed | **Not started — optional** | Measured: everything runs interpreted; the JIT buys speed, not capability (system.md:2094). No longer a prerequisite for anything | | **C** — Preview container | In-app viewing surface for what dev servers serve; LAN hosting | **Not started** | The server half works (vite serves clients outside the app — gated in `verify/devserver`); no in-app viewer exists | | **E** — wasm runtime processes | Real processes: `$PATH`, executable bits, `ps`/`kill`/`&`, pipes between programs; other languages (Python first) as wasm32-wasi artifacts | **Runtime half done** | `pkg install python` downloads the official CPython wasm32-wasi build, hash-checks it, unpacks it (zip reader written here — iOS has no `unzip`) and `python hello.py` runs CPython 3.14.6. `swift/Mouse/Runtimes.swift` + mounts in `NodeEngine`. Gated: `verify/python`, `verify/pkgpython`. Written up in system.md §5b. Missing: `$PATH`, executable bits, background jobs (`&` is still refused by name in the lexer) | From c1e5b167dfe8220f7eb006db15141b43c92e9536 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 22:05:01 -0400 Subject: [PATCH 17/32] lightningcss on wasm, and a file: URL that is the same URL with one slash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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--`, 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 --- .claude/npm-dev-brief.md | 17 +++++-- swift/Mouse/NodeEngine.swift | 17 ++++++- swift/Mouse/PackageManager.swift | 10 ++++ verify/fileurl/node.txt | 5 ++ verify/fileurl/probe.js | 11 ++++ verify/lightningcss/main.swift | 86 ++++++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 verify/lightningcss/main.swift diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index 6e0feac..fe5438a 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -32,9 +32,20 @@ code, and shorthand properties written one per line. - Nothing gates a watcher rooted at the program root asserting no `unlink` when nothing changed. `verify/chokidar` watches a subdirectory, which is why the restart storm was invisible to the suite. -- `tailwindcss()` is still removed from `local__test-2/vite.config.ts` from - debugging. Restoring it needs `lightningcss` → `lightningcss-wasm` in - `PackageManager.wasmSubstitutes`. +- **Tailwind 4 cannot run here, and the reason is not ours to fix.** Restoring + `tailwindcss()` to `local__test-2/vite.config.ts` gets past lightningcss now + (substituted, gated by `verify/lightningcss`) and then stops at + `@tailwindcss/oxide`. Its wasi build declares SHARED memory, and JSC answers + `WebAssembly.Module doesn't parse at byte 398: shared memory is not enabled`. + Measured, so nobody re-derives it: `new WebAssembly.Memory({shared: true})` + succeeds, but a module whose memory section carries the shared flag does not + parse. The gate is `JSC::Options::useSharedArrayBuffer`, and it is RESTRICTED + in Apple's build — `JSC_useSharedArrayBuffer=true` in the environment is + accepted as a name (a bogus name next to it is rejected, so the parse + happened) and `JSC_dumpOptions=1` still prints + `useSharedArrayBuffer=false (default: false)`. So the config keeps tailwind + out, and the project runs. This also bounds every other napi-rs wasi binding + built with threads. ## History — blocker at iteration 9: the PINNED ESM divergence diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index 777b310..654e4f0 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -5242,9 +5242,19 @@ final class NodeEngine: @unchecked Sendable { this.password = credentials && credentials.indexOf(':') >= 0 ? credentials.slice(credentials.indexOf(':') + 1) : ''; this.hostname = match[3] || ''; this.port = match[4] || ''; + // `file:` is a SPECIAL scheme in the URL standard: its serialization always carries + // the authority marker even though it never has a real host, and `localhost` IS the + // empty host. Ours kept whatever the text happened to say, so `new URL('file:' + p)` + // — which is the `import.meta.url` polyfill esbuild emits, and how lightningcss-wasm + // locates its own .wasm — stayed `file:/project/…` with a single slash. `fs` then + // went looking for a file whose name started with `file:`. Only `file:` is handled + // here; `http:/host/x`, which the standard also re-reads as an authority, is left as + // it was. + const isFile = this.protocol === 'file:'; + if (isFile && this.hostname.toLowerCase() === 'localhost') this.hostname = ''; this.host = this.hostname + (this.port ? ':' + this.port : ''); // A hierarchical URL always has a path; node reports '/' where the text has none. - const hierarchical = text.indexOf('//') === this.protocol.length; + const hierarchical = isFile || text.indexOf('//') === this.protocol.length; this._hierarchical = hierarchical; // A path is percent-encoded text, not raw text. Existing '%' escapes are left alone // (re-encoding them would corrupt any URL that arrived already encoded); everything @@ -11606,7 +11616,10 @@ final class NodeEngine: @unchecked Sendable { // contract, appending an mtime to `searchParams` to cache-bust an ESM config import, // which needs an actual URL object rather than a bare { href }. fileURLToPath: function(url) { - const href = typeof url === 'string' ? url : String((url && url.href) || url); + let href = typeof url === 'string' ? url : String((url && url.href) || url); + // A file URL written with one slash is the same URL; node parses the string before + // reading it, so `fileURLToPath('file:/a')` is '/a' rather than a scheme complaint. + if (/^file:(?!\/\/)/i.test(href)) { try { href = new URL(href).href; } catch (error) {} } if (!/^file:\/\//i.test(href)) { const error = new TypeError('The URL must be of scheme file'); error.code = 'ERR_INVALID_URL_SCHEME'; diff --git a/swift/Mouse/PackageManager.swift b/swift/Mouse/PackageManager.swift index d3828dc..4cb43ff 100644 --- a/swift/Mouse/PackageManager.swift +++ b/swift/Mouse/PackageManager.swift @@ -408,6 +408,16 @@ enum PackageManager { static let wasmSubstitutes: [String: String] = [ "rollup": "@rollup/wasm-node", "esbuild": "esbuild-wasm", + // lightningcss loads `lightningcss--` or a sibling `.node` and has no + // wasi branch to fall back to, so on a phone it is simply a missing module — and it is + // not optional: vite reaches for it as a CSS transformer, and tailwind 4 pulls it in + // through `@tailwindcss/node`. Its authors publish `lightningcss-wasm` at the SAME + // version, and that package's `node` export condition is a synchronous build: it reads + // its own wasm with `fs.readFileSync`, instantiates it at module scope, and exports the + // same transform/bundle/Features/composeVisitors surface the native one does. No + // `await init()` for the caller, which is what makes it substitutable at all — the + // package that depends on it calls a sync function and gets one. + "lightningcss": "lightningcss-wasm", ] /// The same idea as `wasmSubstitutes`, but for the shape napi-rs uses: a package lists its diff --git a/verify/fileurl/node.txt b/verify/fileurl/node.txt index ad1cf95..ccd2094 100644 --- a/verify/fileurl/node.txt +++ b/verify/fileurl/node.txt @@ -34,5 +34,10 @@ cachebust file:///cfg/eslint.config.mjs?mtime=12345 cachebust encoded file:///a/b%20c.js?mtime=7 accepts URL object /a/b c.js accepts localhost /a/b.js +special file:/a/b.js -> file:///a/b.js host="" origin=null +special file: -> file:/// host="" origin=null +special file://localhost/a/b.js -> file:///a/b.js host="" origin=null +special relative file:///a/b/c.wasm +special toPath /a/b.js reject https://example.com/a -> ERR_INVALID_URL_SCHEME reject file://otherhost/a -> ERR_INVALID_FILE_URL_HOST diff --git a/verify/fileurl/probe.js b/verify/fileurl/probe.js index e5e8dd7..62cebdf 100644 --- a/verify/fileurl/probe.js +++ b/verify/fileurl/probe.js @@ -18,6 +18,17 @@ out.push('cachebust encoded ' + w.href); // fileURLToPath accepts a URL object as well as a string. out.push('accepts URL object ' + url.fileURLToPath(url.pathToFileURL('/a/b c.js'))); out.push('accepts localhost ' + url.fileURLToPath('file://localhost/a/b.js')); +// `file:` is a SPECIAL scheme: one slash after it is the same URL as three, and `localhost` +// IS the empty host. esbuild's import.meta.url polyfill writes the one-slash form — it is +// literally `new URL('file:' + __filename)` — and lightningcss-wasm reads its own .wasm through +// the result, so a URL left at `file:/…` reached fs as a filename beginning with "file:". +for (const one of ['file:/a/b.js', 'file:', 'file://localhost/a/b.js']) { + const u = new URL(one); + out.push('special ' + one + ' -> ' + u.href + ' host=' + JSON.stringify(u.host) + ' origin=' + u.origin); +} +out.push('special relative ' + new URL('c.wasm', 'file:/a/b/i.js').href); +out.push('special toPath ' + url.fileURLToPath('file:/a/b.js')); + // And rejects what is not a file URL. for (const bad of ['https://example.com/a', 'file://otherhost/a']) { try { url.fileURLToPath(bad); out.push('reject ' + bad + ' -> NO THROW'); } diff --git a/verify/lightningcss/main.swift b/verify/lightningcss/main.swift new file mode 100644 index 0000000..b577291 --- /dev/null +++ b/verify/lightningcss/main.swift @@ -0,0 +1,86 @@ +import Foundation +setvbuf(stdout, nil, _IONBF, 0) +// lightningcss, which iOS cannot load: the package resolves `lightningcss--` or a +// sibling `.node`, and unlike a napi-rs loader it has no wasi branch to fall back to — so it is a +// missing module, and vite reaches for it as a CSS transformer while tailwind 4 pulls it in +// through `@tailwindcss/node`. `wasmSubstitutes` installs the authors' own `lightningcss-wasm` +// under the original name. +// +// Installing it is not the claim worth gating; TRANSFORMING with it is. The wasm package has two +// faces, and only one of them is substitutable: the browser build wants `await init()` before any +// call, while the `node` export condition instantiates at module scope and hands back synchronous +// functions — the shape lightningcss's own callers require. So this runs real CSS through the +// substitute, in this engine, and checks the output is minified CSS rather than a promise. +var failures = 0 +func check(_ condition: Bool, _ label: String) { + if !condition { failures += 1; print(" FAIL: \(label)") } +} + +let scratch = FileManager.default.temporaryDirectory + .appendingPathComponent("lightningcss-\(ProcessInfo.processInfo.processIdentifier)") +defer { try? FileManager.default.removeItem(at: scratch) } +try? FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true) + +do { + _ = try await PackageManager.install(requirements: ["lightningcss": "^1.30.0"], into: scratch) +} catch { + print(" FAIL: install threw: \(error)") + print("LIGHTNINGCSS: install failed — MISMATCH") + exit(1) +} + +// 1. The substitute is what landed, under the original name. +let installed = scratch.appendingPathComponent("node_modules/lightningcss/package.json") +guard let data = try? Data(contentsOf: installed), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + print(" FAIL: node_modules/lightningcss has no package.json") + print("LIGHTNINGCSS: MISMATCH") + exit(1) +} +check(parsed["name"] as? String == "lightningcss-wasm", + "node_modules/lightningcss is lightningcss-wasm, not \(parsed["name"] as? String ?? "?")") +check(FileManager.default.fileExists( + atPath: scratch.appendingPathComponent("node_modules/lightningcss/lightningcss_node.wasm").path), + "the wasm artifact came with it") + +// 2. It transforms, synchronously, in this engine. +let script = """ +const css = require('lightningcss'); +const result = css.transform({ + filename: 'in.css', + code: Buffer.from(['.a {', ' color: #ff0000;', '}', '.b { padding: 0px 0px 0px 0px }'].join('\\n')), + minify: true, +}); +if (result instanceof Promise) { console.log('PROMISE'); } +else { console.log('CODE ' + Buffer.from(result.code).toString('utf8')); } +console.log('FEATURES ' + typeof css.Features); +console.log('COMPOSE ' + typeof css.composeVisitors); +console.log('BUNDLE ' + typeof css.bundle); +""" +let engine = NodeEngine(root: scratch, env: ["PATH": "/usr/bin"]) +let run = await engine.run(source: script, path: NodeEngine.namedRoot + "/probe.js", + argv: ["node", "probe.js"], cwd: NodeEngine.namedRoot, stdin: "") +let lines = run.out.split(separator: "\n").map(String.init) +func line(_ prefix: String) -> String? { + lines.first { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) } +} +if let code = line("CODE ") { + // Minified, both rules present, the hex colour shortened the way lightningcss does it. + check(code.contains(".a"), "the first rule survived: \(code)") + check(code.contains(".b"), "the second rule survived: \(code)") + check(code.contains("red"), "#ff0000 minified to `red`: \(code)") + check(!code.contains("\n "), "the output is minified: \(code)") +} else { + failures += 1 + print(" FAIL: no transform output — \(run.out.isEmpty ? run.err : run.out)") +} +check(line("FEATURES ") == "object" || line("FEATURES ") == "function", "Features is exported") +check(line("COMPOSE ") == "function", "composeVisitors is exported") +check(line("BUNDLE ") == "function", "bundle is exported") + +if failures == 0 { + print("LIGHTNINGCSS: 9 checks — the wasm build installs under the native name and transforms CSS synchronously — MATCH") +} else { + print("LIGHTNINGCSS: \(failures) of 9 checks failed — MISMATCH") + exit(1) +} From 3a47bf3c6edc83a0d1b8da7d7e5309d77605eacb Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 22:15:28 -0400 Subject: [PATCH 18/32] A gate for the root nobody was watching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 8 ++- swift/Mouse/NodeEngine.swift | 7 +-- verify/watchroot/main.swift | 115 +++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 verify/watchroot/main.swift diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index fe5438a..e915c5a 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -29,9 +29,11 @@ code, and shorthand properties written one per line. creation cannot be hooked; the honest fix is wrapping the error constructors, which covers everything thrown by JS and leaves engine-thrown TypeErrors to `util.inspect`, which already rebuilds the header. -- Nothing gates a watcher rooted at the program root asserting no `unlink` when - nothing changed. `verify/chokidar` watches a subdirectory, which is why the - restart storm was invisible to the suite. +- ~~Nothing gates a watcher rooted at the program root.~~ `verify/watchroot` + now does, driving the real shell so it pins the launch cwd itself. Run against + the previous launch path it fails, so it catches the regression. It does not + reproduce the storm: standalone chokidar over a root this size stays quiet even + from `/`, and the tearing needed the dev server's own watcher and churn. - **Tailwind 4 cannot run here, and the reason is not ours to fix.** Restoring `tailwindcss()` to `local__test-2/vite.config.ts` gets past lightningcss now (substituted, gated by `verify/lightningcss`) and then stops at diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index 654e4f0..d0b3481 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -3365,7 +3365,6 @@ final class NodeEngine: @unchecked Sendable { // into this module finds them, and every export reads through a getter rather than // being frozen at end-of-module. var prologue: [String] = [] - var epilogue: [String] = [] var counter = 0 var maskCache: String? @@ -3382,7 +3381,7 @@ final class NodeEngine: @unchecked Sendable { // bytes with strings, comments and regexes blanked — and then applied to the text at // those offsets. A bundle emits JavaScript as data: vite's worker shim is the literal // line `export default function WorkerWrapper(options) {` inside a template string, and - // rewriting it both corrupted the code vite serves and left the epilogue assigning a + // rewriting it both corrupted the code vite serves and left an export assigning a // binding that does not exist. The mask is recomputed per pass because each pass // changes the text; scanning is linear and this is cached by content anyway. func replace(_ pattern: String, _ transform: (NSTextCheckingResult, NSString) -> String) { @@ -3726,7 +3725,7 @@ final class NodeEngine: @unchecked Sendable { // `locator` everything else -> `__esm1.locator` // `.locator` is skipped by the preceding-character test, which is what already kept // `runner.import` intact. - var body = prologue.joined(separator: " ") + " " + text + "\n;" + epilogue.joined(separator: " ") + var body = prologue.joined(separator: " ") + " " + text + "\n;" if !live.isEmpty { let mask = rewriteImportForms(body, meta: false, mask: true).text let ns = body as NSString @@ -3772,7 +3771,7 @@ final class NodeEngine: @unchecked Sendable { // A DECLARATION of the name is never a read — including the // `const x = __esmBinding(…)` emitted just above, which would otherwise // become `const __esm1.x = …`. - var keywordEnd = before + 1 + let keywordEnd = before + 1 var keywordStart = before while keywordStart >= 0, maskNS.character(at: keywordStart) != 0x20, maskNS.character(at: keywordStart) != 0x09, diff --git a/verify/watchroot/main.swift b/verify/watchroot/main.swift new file mode 100644 index 0000000..7253ee2 --- /dev/null +++ b/verify/watchroot/main.swift @@ -0,0 +1,115 @@ +import Foundation +setvbuf(stdout, nil, _IONBF, 0) +// The watcher rooted at the PROJECT ROOT — the shape that took a dev server down and that no +// harness here could see. +// +// chokidar records a directory under its parent: `_getWatchedDir(dirname(dir)).add(basename(dir))`. +// For the root, POSIX says dirname is "/" and basename is "", so it filed a child named "" inside +// the root's own record; on the next read that child was missing, so it called it deleted, and +// removing "" resolved back to the root and tore the whole tree down — an `unlink` for every file +// in the project, including `vite.config.ts`. Vite restarted, built a new watcher, and did it +// again about every three seconds. `verify/chokidar` watches a SUBDIRECTORY and is green +// throughout, which is precisely why this needed its own harness. +// +// The fix is that a program starts at a NAMED root, so the path it watches has a real parent and +// a real basename. That is a property of the launch path, so this drives the actual shell rather +// than calling the engine directly. +// +// What this gate does and does not prove, stated plainly because a gate that oversells itself is +// worse than none. Running it against the previous launch path — the two sites in Shell.swift +// that passed `"/" + cwd` — fails on the root checks, so it does catch the regression that caused +// the storm. It does NOT reproduce the storm: standalone chokidar over a root of this size stayed +// quiet even from "/", and the tearing needed the dev server's own watcher and its own churn. The +// unlink assertions below are therefore an invariant being held, not a reproduction being fixed. +// +// Silence alone would be a false green — a watcher that died reports nothing either — so the run +// ends by demanding a real event. +var failures = 0 +func check(_ condition: Bool, _ label: String) { + if !condition { failures += 1; print(" FAIL: \(label)") } +} + +let base = FileManager.default.temporaryDirectory + .appendingPathComponent("watchroot-\(ProcessInfo.processInfo.processIdentifier)") +try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) +defer { try? FileManager.default.removeItem(at: base) } + +print("installing chokidar…") +do { _ = try await PackageManager.install(requirements: ["chokidar": "^3.6.0"], into: base) } +catch { print(" FAIL: install: \(error)"); print("WATCH ROOT: MISMATCH"); exit(1) } + +// A project shaped like a real one: a config at the root — the file whose loss restarts a dev +// server — plus nested source and an existing directory to walk. +for (name, text) in [("vite.config.ts", "export default {}"), ("package.json", "{\"name\":\"w\"}")] { + try? text.write(to: base.appendingPathComponent(name), atomically: true, encoding: .utf8) +} +try? FileManager.default.createDirectory(at: base.appendingPathComponent("src/lib"), + withIntermediateDirectories: true) +for name in ["src/main.ts", "src/lib/util.ts"] { + try? "export const x = 1".write(to: base.appendingPathComponent(name), atomically: true, encoding: .utf8) +} + +let script = """ +const chokidar = require('chokidar'); +const fs = require('fs'); +const quiet = []; +// '.' is what a dev server watches: the project root, as the program's own cwd. +const watcher = chokidar.watch('.', { + ignoreInitial: true, + ignored: (p) => p.includes('node_modules'), +}); +watcher.on('all', (event, where) => quiet.push(event + ' ' + where.replace(/\\\\/g, '/'))); +watcher.on('ready', () => { + console.log('CWD ' + process.cwd()); + // Writing a file into the ROOT DIRECTORY is what springs the trap: it makes chokidar re-read + // the root's children, and only on that read does the ""-named child it filed earlier look + // deleted. SvelteKit does exactly this — it regenerates `.svelte-kit/` beside the config on + // every request — which is why the storm was a dev-server symptom and not a watcher demo. + fs.mkdirSync('.svelte-kit/generated', { recursive: true }); + fs.writeFileSync('.svelte-kit/generated/root.svelte', '
'); + setTimeout(() => { + fs.writeFileSync('src/added.ts', 'export const y = 2'); + setTimeout(async () => { + await watcher.close(); + console.log('EVENTS ' + (quiet.length ? quiet.join(', ') : '(nothing)')); + console.log('UNLINKS ' + quiet.filter((e) => e.startsWith('unlink')).length); + process.exit(0); + }, 2500); + }, 1500); +}); +""" +try? script.write(to: base.appendingPathComponent("watch.js"), atomically: true, encoding: .utf8) + +@MainActor func msh(_ line: String) async -> String { + let shell = MouseShell() + let outputs = await shell.runProgram(line, context: MouseShell.Context(root: base), + interactive: false) + return outputs.map(\.text).joined(separator: "\n") +} +let out = await msh("node watch.js") +func field(_ prefix: String) -> String? { + out.split(separator: "\n").map(String.init).first { $0.hasPrefix(prefix) } + .map { String($0.dropFirst(prefix.count)) } +} + +let cwd = field("CWD ") ?? "(none)" +// The root a program starts at must have a parent and a basename. "/" has neither, and that is +// the entire bug: `dirname("/")` is "/" and `basename("/")` is "". +check(cwd != "/", "a program does not start at \"/\" — it starts at \(cwd)") +check(cwd == NodeEngine.namedRoot, "the launch cwd is the named root \(NodeEngine.namedRoot), got \(cwd)") + +let events = field("EVENTS ") ?? "(no output)" +// The storm reported every file in the project as deleted. Nothing was deleted here. +check(field("UNLINKS ") == "0", "writing into the root deletes nothing — \(events)") +check(!events.contains("unlink vite.config.ts"), + "the config file in particular survives — losing it is what restarted the server") +// Alive, not merely silent: a watcher that died would also report no unlink. +check(events.contains("add src/added.ts"), "the watcher still reports a real add — \(events)") + +if failures == 0 { + print("WATCH ROOT: 5 checks — a project root stays watchable while it is written into — MATCH") +} else { + print("WATCH ROOT: \(failures) of 5 checks failed — MISMATCH") + if out.isEmpty { print(" (the program printed nothing)") } else { print(" output:\n\(out.prefix(800))") } + exit(1) +} From 7dfec547e078682c04bea9f7691bafe0f140e045 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Tue, 11 Aug 2026 23:43:02 -0400 Subject: [PATCH 19/32] An error that says what went wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ()` 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 --- .claude/npm-dev-brief.md | 22 ++++++--- swift/Mouse/NodeEngine.swift | 86 ++++++++++++++++++++++++++++++++- verify/stackshape/main.swift | 94 ++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 verify/stackshape/main.swift diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index e915c5a..c39774b 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -21,14 +21,20 @@ that left svelte's `dev` a snapshot after all — prose inside a string read as code, and shorthand properties written one per line. ### Still open -- `error.stack` on JSC carries no `Name: message` header the way V8's does. - Every framework that logs `err.stack` — SvelteKit's `format_server_error`, - vite's optimizer — therefore prints frames with no message, which cost most - of the diagnosis time in this loop and costs a user the same. `stack` is an - own writable data property on both JS-created and engine-thrown errors, so - creation cannot be hooked; the honest fix is wrapping the error constructors, - which covers everything thrown by JS and leaves engine-thrown TypeErrors to - `util.inspect`, which already rebuilds the header. +- ~~`error.stack` carries no `Name: message` header.~~ Done for every error + JAVASCRIPT constructs — the error constructors are wrapped and the stack is + V8-shaped, gated by `verify/stackshape` against real node. An error the ENGINE + throws still reads as bare frames through `.stack`, because JSC materialises + `stack` during construction and no hook sees that; console and util.inspect + rebuild the header for those. Worth knowing if a message ever goes missing + again: the remaining hole is engine-thrown TypeErrors read via `.stack`. +- `verify/nodejs` has ONE pre-existing mismatch, `event-sequences`: `http client + request` and `http server side` both report TIMED OUT where node reports + `socket,response,res-end,close` and `request,res-finish,req-end`. Measured + against the engine as it was before the stack work — same 104 lines, same one + mismatch — so it is not fallout from that, and it was not introduced by this + loop. `verify/http` passes, so it is narrower than "http is broken". Unclaimed + and unfixed; someone should take it. - ~~Nothing gates a watcher rooted at the program root.~~ `verify/watchroot` now does, driving the real shell so it pins the launch cwd itself. Run against the previous launch path it fails, so it catches the regression. It does not diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index d0b3481..f5fa1ee 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -4941,6 +4941,90 @@ final class NodeEngine: @unchecked Sendable { }; globalThis.CountQueuingStrategy = class CountQueuingStrategy { constructor(options) { this.highWaterMark = options && options.highWaterMark || 1; } }; globalThis.ByteLengthQueuingStrategy = class ByteLengthQueuingStrategy { constructor(options) { this.highWaterMark = options && options.highWaterMark || 16384; } }; + // A V8 stack reads "TypeError: message" and then " at fn (file:line:col)" lines. JSC + // gives the frames alone, "fn@file:line:col", with no first line at all — so on this + // engine anything that logs `err.stack` prints frames and no reason. SvelteKit's + // format_server_error prints exactly that and nothing else, which is why a page that + // failed to render showed forty frames and never said what went wrong; and vite's + // ssrRewriteStacktrace matches `/^ {4}at /`, so no SSR frame was ever mapped back to the + // .svelte file it came from either. + // + // JSC materialises `stack` as an own data property while the error is being constructed — + // on errors the ENGINE throws as much as on ones JavaScript builds — so there is no hook + // that sees every error. What is reachable is every error JavaScript constructs, which is + // what a library throws on purpose, and that is what this covers. A TypeError the engine + // raises still reads as bare frames through `.stack`; console and util.inspect rebuild the + // header for those, as they already did. + globalThis.__mouseV8Frames = function(raw) { + const out = []; + for (const line of String(raw || '').split('\n')) { + if (!line) continue; + if (/^\s+at\s/.test(line)) { out.push(line); continue; } // already V8-shaped + const at = line.lastIndexOf('@'); + if (at < 0) { out.push(' at ' + line); continue; } + const name = line.slice(0, at); + const where = line.slice(at + 1) || ''; + out.push(name ? ' at ' + name + ' (' + where + ')' : ' at ' + where); + } + return out.join('\n'); + }; + (function() { + const kinds = [Error, EvalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError]; + if (typeof AggregateError === 'function') kinds.push(AggregateError); + const replacements = new Map(); + for (const Native of kinds) { + // Named, and the name is load-bearing: the frames JSC captures include this function + // and whatever native frames sit under it, none of which are part of the error's + // history — V8 does not show the Error constructor either. Counting frames off the top + // guessed wrong (it left `at Wrapped ()` as the innermost frame), so the + // cut is made at the marker instead. + const Wrapped = function __mouseErrorConstructor() { + const error = Reflect.construct(Native, arguments, new.target || Wrapped); + const lines = String(error.stack || '').split('\n'); + const mine = lines.findIndex(function(l) { return l.indexOf('__mouseErrorConstructor@') === 0; }); + const raw = (mine >= 0 ? lines.slice(mine + 1) : lines).join('\n'); + let text = null; + // An ACCESSOR, not a value: building the string costs a split and a join over every + // frame, and the overwhelming majority of errors are constructed, caught and never + // asked for a stack. It also matches V8, where `stack` is lazy — and vite's + // rebindErrorStacktrace, which reads the descriptor before overwriting, takes the + // configurable branch either way. + Object.defineProperty(error, 'stack', { + configurable: true, + enumerable: false, + get: function() { + if (text === null) { + const header = (error.name || 'Error') + + (error.message ? ': ' + error.message : ''); + const frames = globalThis.__mouseV8Frames(raw); + text = frames ? header + '\n' + frames : header; + } + return text; + }, + set: function(value) { text = value; }, + }); + return error; + }; + // The prototype is the native one, untouched, so `instanceof` keeps answering for + // errors the engine threw as well as these, and `e.constructor === Error` stays true. + Wrapped.prototype = Native.prototype; + Object.defineProperty(Native.prototype, 'constructor', + { value: Wrapped, writable: true, configurable: true }); + Object.defineProperty(Wrapped, 'name', { value: Native.name, configurable: true }); + for (const key of Object.getOwnPropertyNames(Native)) { + if (key === 'prototype' || key === 'name' || key === 'length') continue; + const descriptor = Object.getOwnPropertyDescriptor(Native, key); + if (descriptor) Object.defineProperty(Wrapped, key, descriptor); + } + replacements.set(Native, Wrapped); + } + // `TypeError.__proto__ === Error` in the language, and code reads it; keep the shape. + for (const [Native, Wrapped] of replacements) { + const parent = replacements.get(Object.getPrototypeOf(Native)); + if (parent) Object.setPrototypeOf(Wrapped, parent); + globalThis[Native.name] = Wrapped; + } + })(); // V8's stack-trace protocol: when Error.prepareStackTrace is set, captureStackTrace // hands it structured CallSite objects (depd — under express — walks them). JSC's // native captureStackTrace ignores the protocol, so emulate it from JSC stack lines @@ -4972,7 +5056,7 @@ final class NodeEngine: @unchecked Sendable { target.stack = Error.prepareStackTrace(target, callSites); } else { target.stack = (target.name || 'Error') + (target.message ? ': ' + target.message : '') + '\n' - + raw.map(function(l){ return ' at ' + l; }).join('\n'); + + globalThis.__mouseV8Frames(raw.join('\n')); } }; globalThis.atob = function(base64) { return Buffer.from(String(base64), 'base64').toString('binary'); }; diff --git a/verify/stackshape/main.swift b/verify/stackshape/main.swift new file mode 100644 index 0000000..e5bcb45 --- /dev/null +++ b/verify/stackshape/main.swift @@ -0,0 +1,94 @@ +import Foundation +setvbuf(stdout, nil, _IONBF, 0) +// The SHAPE of error.stack, against real node. +// +// V8 writes "TypeError: message" and then " at fn (file:line:col)". JSC writes the frames +// alone, "fn@file:line:col", with no first line — so on this engine every tool that logs +// `err.stack` printed frames and no reason. SvelteKit's format_server_error prints exactly that +// and nothing else; vite's ssrRewriteStacktrace matches `/^ {4}at /` and so mapped no SSR frame +// back to its source. Errors JavaScript constructs are now V8-shaped. +// +// Only comparable facts are printed — booleans, headers, names. Absolute paths and column +// numbers differ between engines by nature, and `verify/stackline` is where line numbers are +// compared. +let realNode = "/Users/thyfriendlyfox/.local/bin/node" +let base = FileManager.default.temporaryDirectory + .appendingPathComponent("stackshape-\(ProcessInfo.processInfo.processIdentifier)") +try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) +defer { try? FileManager.default.removeItem(at: base) } + +let script = """ +const show = []; +function inner() { throw new TypeError('made by js'); } +function outer() { inner(); } +try { outer(); } catch (e) { + const lines = String(e.stack).split('\\n'); + show.push('header ' + lines[0]); + show.push('frame is v8 ' + /^ {4}at /.test(lines[1])); + show.push('frame is inner ' + /^ {4}at inner[ (]/.test(lines[1])); + show.push('no ctor frame ' + !/Wrapped|__mouseError|Reflect/.test(String(e.stack))); +} +// Every constructor the language defines, each keeping its own name in the header. +for (const Kind of [Error, EvalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError]) { + show.push('kind ' + Kind.name + ' ' + String(new Kind('boom').stack).split('\\n')[0]); +} +show.push('no message ' + JSON.stringify(String(new Error().stack).split('\\n')[0])); +// The object is still an error in every way code tests for. +show.push('instanceof ' + (new TypeError('x') instanceof TypeError) + ' ' + (new TypeError('x') instanceof Error)); +show.push('constructor ' + (new Error('x').constructor === Error)); +show.push('proto chain ' + (Object.getPrototypeOf(TypeError) === Error)); +show.push('names ' + TypeError.name + ' ' + new TypeError('x').name); +// Subclassing, which is how every library defines its own error type. +class HttpError extends Error { constructor(m) { super(m); this.name = 'HttpError'; } } +const sub = new HttpError('not found'); +show.push('subclass ' + (sub instanceof HttpError) + ' ' + (sub instanceof Error) + ' ' + String(sub.stack).split('\\n')[0]); +// vite's rebindErrorStacktrace reads the descriptor and then overwrites; both branches must work. +const one = new Error('a'); +const descriptor = Object.getOwnPropertyDescriptor(one, 'stack'); +show.push('descriptor configurable=' + descriptor.configurable + ' enumerable=' + descriptor.enumerable); +one.stack = 'replaced'; +show.push('settable ' + one.stack); +const two = new Error('b'); +Object.defineProperty(two, 'stack', { value: 'redefined', configurable: true, writable: true }); +show.push('redefinable ' + two.stack); +// A cause chain still prints, and `throw` still carries the object unchanged. +show.push('cause ' + (new Error('outer', { cause: new Error('inner') }).cause.message)); +console.log(show.join('\\n')); +""" +try? script.write(to: base.appendingPathComponent("probe.js"), atomically: true, encoding: .utf8) + +let process = Process() +process.executableURL = URL(fileURLWithPath: realNode) +process.arguments = ["probe.js"] +process.currentDirectoryURL = base +let pipe = Pipe() +process.standardOutput = pipe +process.standardError = Pipe() +try? process.run() +let nodeText = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) +process.waitUntilExit() + +let engine = NodeEngine(root: base, env: ["PATH": "/usr/bin"]) +let mine = await engine.run(source: script, path: NodeEngine.namedRoot + "/probe.js", + argv: ["node", "probe.js"], cwd: NodeEngine.namedRoot, stdin: "") + +func lines(_ text: String) -> [String] { + text.trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\n", omittingEmptySubsequences: false).map(String.init) +} +let theirs = lines(nodeText), ours = lines(mine.out) +var wrong = 0 +for i in 0.." + let got = i < ours.count ? ours[i] : "" + if want != got { wrong += 1; print(" node: \(want)\n ours: \(got)") } +} +if wrong == 0 && !ours.isEmpty { + print("STACK SHAPE MATCH — \(theirs.count) facts identical to node: the header, V8 frames, " + + "the constructor's own frame absent, subclasses, and a stack that is still " + + "configurable and settable") +} else { + print("STACK SHAPE: \(wrong) of \(max(theirs.count, ours.count)) lines differ — MISMATCH") + if !mine.err.isEmpty { print("stderr: \(mine.err.prefix(600))") } + exit(1) +} From 213226abc2d600e392349f6bdf54ee2263772a72 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 00:11:40 -0400 Subject: [PATCH 20/32] A server nobody could reach by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .claude/npm-dev-brief.md | 25 ++++++++-------- swift/Mouse/NodeEngine.swift | 7 +++-- swift/Mouse/NodeSockets.swift | 56 +++++++++++++++++++++++++---------- verify/devserver/main.swift | 25 +++++++++++----- 4 files changed, 77 insertions(+), 36 deletions(-) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index c39774b..f607d07 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -28,18 +28,19 @@ code, and shorthand properties written one per line. `stack` during construction and no hook sees that; console and util.inspect rebuild the header for those. Worth knowing if a message ever goes missing again: the remaining hole is engine-thrown TypeErrors read via `.stack`. -- `verify/nodejs` has ONE pre-existing mismatch, `event-sequences`: `http client - request` and `http server side` both report TIMED OUT where node reports - `socket,response,res-end,close` and `request,res-finish,req-end`. Measured - against the engine as it was before the stack work — same 104 lines, same one - mismatch — so it is not fallout from that, and it was not introduced by this - loop. `verify/http` passes, so it is narrower than "http is broken". Unclaimed - and unfixed; someone should take it. -- ~~Nothing gates a watcher rooted at the program root.~~ `verify/watchroot` - now does, driving the real shell so it pins the launch cwd itself. Run against - the previous launch path it fails, so it catches the regression. It does not - reproduce the storm: standalone chokidar over a root this size stays quiet even - from `/`, and the tearing needed the dev server's own watcher and churn. +- ~~`verify/nodejs` has a pre-existing `event-sequences` mismatch.~~ Fixed, and + the cause was worth having: `listen()` with no host bound `0.0.0.0`, IPv4 + only, where node binds `::` with IPV6_V6ONLY off. `localhost` resolves to + `::1` first on this platform, so a program that called its own server BY NAME + was refused — `http.get({ port })`, which is how node's own docs write it. + Sockets given an explicit '127.0.0.1' always worked, which is exactly why it + hid for so long. +- `verify/devserver` was FLAKY and is no longer: it slept a fixed six seconds + for vite to boot, which is enough alone and not enough beside a parallel + build. It now polls for the server. Worth remembering how it read: a red run + during a busy sweep looked exactly like a regression, and was bisected across + five commits before two clean re-runs showed the same binary passing. When a + gate fails once under load, re-run it before believing it. - **Tailwind 4 cannot run here, and the reason is not ours to fix.** Restoring `tailwindcss()` to `local__test-2/vite.config.ts` gets past lightningcss now (substituted, gated by `verify/lightningcss`) and then stops at diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index f5fa1ee..871a1ed 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -17511,7 +17511,10 @@ final class NodeEngine: @unchecked Sendable { this._address = null; } this._closing = false; - let port = 0, host = '0.0.0.0', backlog = 511; + // EMPTY, not '0.0.0.0': the host being absent is information the socket layer needs, + // because node's no-host wildcard is dual-stack `::` and not IPv4's `0.0.0.0`. Naming + // the IPv4 wildcard here threw that away before it could be acted on. + let port = 0, host = '', backlog = 511; // A path listens on a socket FILE. const unixPath = (args[0] && typeof args[0] === 'object' && args[0].path) ? String(args[0].path) : (typeof args[0] === 'string' && !/^\d+$/.test(args[0]) ? args[0] : null); @@ -17526,7 +17529,7 @@ final class NodeEngine: @unchecked Sendable { } if (args[0] && typeof args[0] === 'object') { port = args[0].port || 0; - host = args[0].host || '0.0.0.0'; + host = args[0].host || ''; backlog = args[0].backlog || backlog; } else { port = Number(args[0]) || 0; diff --git a/swift/Mouse/NodeSockets.swift b/swift/Mouse/NodeSockets.swift index 6e299ce..40bf7ee 100644 --- a/swift/Mouse/NodeSockets.swift +++ b/swift/Mouse/NodeSockets.swift @@ -291,26 +291,52 @@ final class SocketTable: @unchecked Sendable { ai_protocol: 0, ai_addrlen: 0, ai_canonname: nil, ai_addr: nil, ai_next: nil) var list: UnsafeMutablePointer? - let name = host.isEmpty ? "0.0.0.0" : host - let status = getaddrinfo(name, String(port), &hints, &list) - guard status == 0, let first = list else { + // NO HOST means the wildcard, and node's wildcard is `::` with IPV6_V6ONLY off — one + // dual-stack socket that answers IPv6 and IPv4 alike. Ours bound "0.0.0.0", IPv4 + // only, and `localhost` resolves to ::1 FIRST on this platform, so a program that + // called its own server by name was refused: `http.get({ port })` with no host, which + // is how node's own docs write it, could not reach a server this engine had just + // started. Sockets given an explicit '127.0.0.1' worked, which is exactly why it hid. + let wildcard = host.isEmpty + let status = getaddrinfo(wildcard ? nil : host, String(port), &hints, &list) + guard status == 0, let list else { fail(id: id, handler: handler, message: "getaddrinfo \(host)", code: "EADDRNOTAVAIL") return } defer { freeaddrinfo(list) } - - let fd = socket(first.pointee.ai_family, SOCK_STREAM, 0) - guard fd >= 0 else { - fail(id: id, handler: handler, message: "socket failed", code: errnoCode()) - return + let name = wildcard ? "::" : host + + var candidates: [addrinfo] = [] + var cursor: UnsafeMutablePointer? = list + while let current = cursor { candidates.append(current.pointee); cursor = current.pointee.ai_next } + // IPv6 first for the wildcard, because only the v6 socket can carry both families. + if wildcard { + candidates = candidates.filter { $0.ai_family == AF_INET6 } + + candidates.filter { $0.ai_family != AF_INET6 } + } + + var fd: Int32 = -1 + var lastCode = "EADDRNOTAVAIL" + for candidate in candidates { + let trial = socket(candidate.ai_family, SOCK_STREAM, 0) + if trial < 0 { lastCode = errnoCode(); continue } + var one: Int32 = 1 + setsockopt(trial, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size)) + setsockopt(trial, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + if wildcard, candidate.ai_family == AF_INET6 { + var off: Int32 = 0 + setsockopt(trial, IPPROTO_IPV6, IPV6_V6ONLY, &off, socklen_t(MemoryLayout.size)) + } + if bind(trial, candidate.ai_addr, candidate.ai_addrlen) == 0 { fd = trial; break } + lastCode = errnoCode() + close(trial) + // A port already taken is an ANSWER, not a reason to try the other family: + // falling through would quietly hand out a second server on a busy port and + // turn EADDRINUSE — which callers act on — into silence. + if lastCode == "EADDRINUSE" || lastCode == "EACCES" { break } } - var one: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size)) - setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) - guard bind(fd, first.pointee.ai_addr, first.pointee.ai_addrlen) == 0 else { - let code = errnoCode() - close(fd) - fail(id: id, handler: handler, message: "listen \(code) \(name):\(port)", code: code) + guard fd >= 0 else { + fail(id: id, handler: handler, message: "listen \(lastCode) \(name):\(port)", code: lastCode) return } guard Darwin.listen(fd, Int32(backlog)) == 0 else { diff --git a/verify/devserver/main.swift b/verify/devserver/main.swift index 0950e22..7684cc2 100644 --- a/verify/devserver/main.swift +++ b/verify/devserver/main.swift @@ -119,15 +119,26 @@ let vitePrompt = await MainActor.run { () -> Task<[MouseShell.Output], Never> in } } _ = await vitePrompt.value -try? await Task.sleep(nanoseconds: 6_000_000_000) +// Wait for the SERVER, not for a duration. Six seconds was enough on an idle machine and not +// enough beside a parallel build, so this harness failed about one run in three under load and +// passed on its own — the worst way for a gate to behave, because a flake and a regression read +// identically and the next person believes whichever they saw first. It cost an afternoon here: +// a red run during a busy sweep was taken for a real break and bisected across five commits +// before two clean re-runs showed the binary passing. var served = "no answer" -do { - let (data, response) = try await URLSession.shared.data(from: URL(string: "http://127.0.0.1:5395/src/main.ts")!) - let code = (response as? HTTPURLResponse)?.statusCode ?? 0 - let body = String(decoding: data, as: UTF8.self) - served = "\(code) \(body.contains("hello from a phone") ? "transformed the module" : "unexpected body")" -} catch { served = "request failed: \(error.localizedDescription)" } +for attempt in 1...60 { + do { + let (data, response) = try await URLSession.shared.data(from: URL(string: "http://127.0.0.1:5395/src/main.ts")!) + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + let body = String(decoding: data, as: UTF8.self) + served = "\(code) \(body.contains("hello from a phone") ? "transformed the module" : "unexpected body")" + break + } catch { + served = "request failed after \(attempt)s: \(error.localizedDescription)" + try? await Task.sleep(nanoseconds: 1_000_000_000) + } +} print("`npm run dev` from the prompt: \(served)") let viteLines = await viteTranscript.snapshot() print("vite said (\(viteLines.count) lines): \(viteLines.map { $0.debugDescription }.joined(separator: " "))") From 1acede57d5a29376b6dc71e044ad34bf8356a080 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 00:17:04 -0400 Subject: [PATCH 21/32] Dev servers in general, and seven files that were nobody's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 6 ++ STATUS.md | 2 +- verify/nodejs/dbg.swift | 10 ---- verify/nodejs/dbg2/main.swift | 10 ---- verify/nodejs/dbg3/main.swift | 3 - verify/nodejs/dbg3main.swift | 3 - verify/nodejs/s.txt | 102 ---------------------------------- verify/nodejs/sdbg/main.swift | 17 ------ verify/nodejs/suite.txt | 79 -------------------------- 9 files changed, 7 insertions(+), 225 deletions(-) delete mode 100644 verify/nodejs/dbg.swift delete mode 100644 verify/nodejs/dbg2/main.swift delete mode 100644 verify/nodejs/dbg3/main.swift delete mode 100644 verify/nodejs/dbg3main.swift delete mode 100644 verify/nodejs/s.txt delete mode 100644 verify/nodejs/sdbg/main.swift delete mode 100644 verify/nodejs/suite.txt diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index f607d07..3857dd3 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -6,6 +6,12 @@ curl http://localhost:5173/ HTTP 200, 33105 bytes, the rendered app node_modules/.vite/deps 20 dependencies pre-bundled +Not a SvelteKit special case, which the brief asks for explicitly. Green on the +same engine sources: `reactdev` (vite serves .tsx with types erased and JSX +compiled), `hmr` (the watcher's edit pushed down a real WebSocket), `firstrun` +(`npm create vite` scaffolds, installs and serves, end to end), `vite`, `npmrun` +and `tscwatch`. + The scan no longer fails. The one remaining sighting of "Failed to run dependency scan" came from a harness that called `server.close()` while the scan was still in flight — vite's own `ERR_CLOSED_SERVER`, correctly reported. diff --git a/STATUS.md b/STATUS.md index 534e863..e9a26c8 100644 --- a/STATUS.md +++ b/STATUS.md @@ -16,7 +16,7 @@ numbers some of the same ground differently; the mapping is noted per row. | **T** — terminal screen | VT100/xterm grid, ANSI parser, TUI hosting, keyboard encoding | **Done** | `swift/Mouse/TerminalScreen.swift`, `TerminalWidth.swift`, `TerminalPrograms.swift`. Gated: screen corpus, pyte cross-check, wide-char/alt-screen/DECCKM/bracketed-paste harnesses (`verify/tty`, `altscreen`, `widetui`, `inkwide`) | | **A** — shell language | msh's POSIX-subset grammar: control flow, `$()`, functions, redirects | **Done** | `swift/Mouse/ShellLanguage.swift`. Gated against real `/bin/sh` on the 25-script corpus (`verify/shell`) | | **F** — package manager | Real npm registry, semver, integrity, lockfiles; `npm run`/`create`/`npx`; native-binary → wasm-build substitution | **Done** | `swift/Mouse/PackageManager.swift`. Gated: semver corpus vs pnpm, `verify/pkg`, `firstrun` (scaffold→install→dev end to end) | -| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. ~88-gate suite green at 953dd98 (`verify/`), fixtures byte-identical to real node v22 | +| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. 146-harness suite (`verify/`), fixtures byte-identical to real node v22. Added since: `lightningcss` (the native CSS transformer, substituted with its authors' wasm build), `watchroot` (a watcher rooted at the project root, which is where the restart storm lived), `stackshape` (error.stack carrying its message, the way V8 writes it) | | **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). A real SvelteKit project runs `npm run dev` on the device: starts once, pre-bundles its 20 dependencies, and answers a curl from the Mac with HTTP 200 and the rendered 33 kB page. Remaining piece is the Preview surface (phase C) | | **B** — WebView JIT | Move JS/wasm execution into WKWebView for JIT speed | **Not started — optional** | Measured: everything runs interpreted; the JIT buys speed, not capability (system.md:2094). No longer a prerequisite for anything | | **C** — Preview container | In-app viewing surface for what dev servers serve; LAN hosting | **Not started** | The server half works (vite serves clients outside the app — gated in `verify/devserver`); no in-app viewer exists | diff --git a/verify/nodejs/dbg.swift b/verify/nodejs/dbg.swift deleted file mode 100644 index 7b53e8f..0000000 --- a/verify/nodejs/dbg.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation -let dir = URL(fileURLWithPath: FileManager.default.temporaryDirectory.path + "/chalk-dbg") -try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) -if !FileManager.default.fileExists(atPath: dir.appendingPathComponent("node_modules/chalk").path) { - _ = try await PackageManager.install(requirements: ["chalk": "5.3.0"], into: dir) -} -let script = "import chalk from 'chalk';\nconsole.log(chalk.red('warning'));" -let engine = NodeEngine(root: dir, env: [:]) -let result = await engine.run(source: script, path: "/main.mjs", argv: ["node", "/main.mjs"], cwd: "/", stdin: "") -print("status \(result.status)\nOUT: \(result.out)ERR: \(result.err)") diff --git a/verify/nodejs/dbg2/main.swift b/verify/nodejs/dbg2/main.swift deleted file mode 100644 index 7b53e8f..0000000 --- a/verify/nodejs/dbg2/main.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation -let dir = URL(fileURLWithPath: FileManager.default.temporaryDirectory.path + "/chalk-dbg") -try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) -if !FileManager.default.fileExists(atPath: dir.appendingPathComponent("node_modules/chalk").path) { - _ = try await PackageManager.install(requirements: ["chalk": "5.3.0"], into: dir) -} -let script = "import chalk from 'chalk';\nconsole.log(chalk.red('warning'));" -let engine = NodeEngine(root: dir, env: [:]) -let result = await engine.run(source: script, path: "/main.mjs", argv: ["node", "/main.mjs"], cwd: "/", stdin: "") -print("status \(result.status)\nOUT: \(result.out)ERR: \(result.err)") diff --git a/verify/nodejs/dbg3/main.swift b/verify/nodejs/dbg3/main.swift deleted file mode 100644 index 50ddf58..0000000 --- a/verify/nodejs/dbg3/main.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Foundation -let source = try String(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1]), encoding: .utf8) -print(NodeEngine.transpileESM(source)) diff --git a/verify/nodejs/dbg3main.swift b/verify/nodejs/dbg3main.swift deleted file mode 100644 index 50ddf58..0000000 --- a/verify/nodejs/dbg3main.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Foundation -let source = try String(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1]), encoding: .utf8) -print(NodeEngine.transpileESM(source)) diff --git a/verify/nodejs/s.txt b/verify/nodejs/s.txt deleted file mode 100644 index 1fdceb1..0000000 --- a/verify/nodejs/s.txt +++ /dev/null @@ -1,102 +0,0 @@ -fixture console-format: match -fixture path-module: match -fixture fs-roundtrip: match -fixture modules-local: match -fixture events-module: match -fixture buffer-module: match -fixture argv-env-exit: match -fixture timers-order: match -fixture async-await: match -fixture throw-exits-1: match -fixture util-module: match -fixture assert-module: match -fixture fs-stream-events: match -fixture fetch-api-family: match -fixture core-module-fillins: match -fixture stream-state-props: match -fixture stream-es5-inherits: match -fixture emitter-es5-call: match -fixture zlib-incremental: match -fixture utf8-lenient-decode: match -fixture stack-has-source: match -fixture fs-async-callbacks: match -fixture buffer-binary-methods: match -fixture zlib-classes: match -fixture fs-fd-as-path: match -fixture events-lazy-mixin: match -fixture callsite-protocol: match -fixture microtask-only-tla: match -fixture dynamic-import-cjs: match -fixture filename-shadow-esm: match -fixture circular-live-exports: match -fixture styletext-als-pipe: match -fixture readdir-dirent: match -fixture realpath-webcrypto: match -fixture assert-more: match -fixture uncaught-handled: match -fixture uncaught-handler-exits: match -fixture events-error-throw: match -fixture timer-objects: match -fixture fs-fd-sync: match -fixture stream-readable: match -fixture stream-writable: match -fixture stream-transform: match -fixture stream-pipeline: match -fixture stream-pipeline-promises: match -fixture stream-async-iter: match -fixture fs-streams: match -fixture crypto-module: match -fixture zlib-module: match -fixture zlib-streams: match -fixture readline-file: match -fixture stream-error-pipeline: match -fixture node-modules: match -fixture mkdirp-bin: our engine ran the installed bin and created the directory -fixture esm-local: match -fixture esm-chalk: match -fixture esm-tla: match -fixture commander-cli: match -fixture anthropic-sdk: match -fixture tsc-cli: match -fixture yargs-cli: match -fixture child-process: match -fixture core-surface: match -fixture url-resolution: match -fixture ciphers-and-kdfs: match (identical ciphertext, tags and derived keys) -cross-engine crypto: what we seal real node opens, and what node seals we open -cross-engine signing: real node verifies our ECDSA and Ed25519 signatures, and we verify node's -fixture signing-shape: match -key types with no system implementation refuse by name (dsa, dh, x448) -fixture fetch-streaming: match (chunks arrive as they are sent, like node) -fixture buffer-over-arraybuffer: match (wasm memory is shared, not copied) -fixture dot-require: match -cross-engine ECDH: the same shared secret on both sides, for every curve -fixture refusal-truth: every refusal names a reason and none is stale -fixture stream-state: autoDestroy, and every flag true when node says so -fixture stream-surface: unpipe stops the flow, and the getters report reality -fixture console: table box-drawing, group indentation, count/time, and stream routing -fixture inspect-format: Maps, Sets, cycles, getters and console.log's own rules -fixture encodings: every named encoding, both directions, and a held split character -fixture event-sequences: nine lifecycles, and two named ordering divergences -fixture error-codes: codes, syscalls, and refusing what node refuses -fixture abort-signals: ten cancellable APIs all cancel -fixture options-third: byte ranges, inspect depth, authTagLength, once+signal -fixture options-second: an AbortSignal aborts, and what msh cannot do says so -fixture options-honoured: flags, modes, recursion, backpressure and zlib levels -fixture text-streams: a character split across chunks survives the decode -fixture http-close: idle connections drop, in-flight ones survive until told not to -fixture stream-operators: map/filter/take/drop/reduce and the empty-stream cases -fixture crypto-streams: hashes pipe, and read() keeps bytes intact -fixture buffer-numerics: floats, BigInt64, variable-width ints, swaps and aliases -fixture dns-resolvers: the family is present, and TLSA still names its reason -fixture brotli: round trips, streams in pieces, and zstd still refuses -fixture diffie-hellman: X25519 and EC agreement, finite-field still refused -fixture glob: node's exact file lists, and the two refusals name measured reasons -fixture scrypt: RFC 7914 vectors byte for byte, and node's parameter rules -fixture host-event-ticks: nextTick beats promises on every route out of the host -fixture loop-order: ticks beat promises, and ports are their own phase -fixture broadcast-channel: same-name fan-out, and a port queue that drains -fixture http-hangup: a dead connection errors and leaves the pool -fixture dns-lookup: match -fixture fetch-http: match -PHASE G: ALL PASS diff --git a/verify/nodejs/sdbg/main.swift b/verify/nodejs/sdbg/main.swift deleted file mode 100644 index 14b33b1..0000000 --- a/verify/nodejs/sdbg/main.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -let dir = FileManager.default.temporaryDirectory.appendingPathComponent("stream-dbg") -try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) -let script = """ -const sp = require('stream/promises'); -console.log('sp keys:', Object.keys(sp).join(',')); -const { pipeline } = sp; -const { Readable, Writable } = require('stream'); -const out = []; -pipeline( - Readable.from(['p', 'q']), - new Writable({ write(c, e, cb) { out.push(String(c)); cb(); } }) -).then(() => console.log('resolved', out.join('')), (e) => console.log('rejected', e.message)); -""" -let engine = NodeEngine(root: dir, env: [:]) -let result = await engine.run(source: script, path: "/main.js", argv: ["node", "/main.js"], cwd: "/", stdin: "") -print("status \(result.status)\nOUT: \(result.out)ERR: \(result.err)") diff --git a/verify/nodejs/suite.txt b/verify/nodejs/suite.txt deleted file mode 100644 index 596a7d6..0000000 --- a/verify/nodejs/suite.txt +++ /dev/null @@ -1,79 +0,0 @@ -fixture console-format: match -fixture path-module: match -fixture fs-roundtrip: match -fixture modules-local: match -fixture events-module: match -fixture buffer-module: match -fixture argv-env-exit: match -fixture timers-order: match -fixture async-await: match -fixture throw-exits-1: match -fixture util-module: match -fixture assert-module: match -fixture fs-stream-events: match -fixture fetch-api-family: match -fixture core-module-fillins: match -fixture stream-state-props: match -fixture stream-es5-inherits: match -fixture emitter-es5-call: match -fixture zlib-incremental: match -fixture utf8-lenient-decode: match -fixture stack-has-source: match -fixture fs-async-callbacks: match -fixture buffer-binary-methods: match -fixture zlib-classes: match -fixture fs-fd-as-path: match -fixture events-lazy-mixin: match -fixture callsite-protocol: match -fixture microtask-only-tla: match -fixture dynamic-import-cjs: match -fixture filename-shadow-esm: match -fixture circular-live-exports: match -fixture styletext-als-pipe: match -fixture readdir-dirent: match -fixture realpath-webcrypto: match -fixture assert-more: match -fixture uncaught-handled: match -fixture uncaught-handler-exits: match -fixture events-error-throw: match -fixture timer-objects: match -fixture fs-fd-sync: match -fixture stream-readable: match -fixture stream-writable: match -fixture stream-transform: match -fixture stream-pipeline: match -fixture stream-pipeline-promises: match -fixture stream-async-iter: match -fixture fs-streams: match -fixture crypto-module: match -fixture zlib-module: match -fixture zlib-streams: match -fixture readline-file: match -fixture stream-error-pipeline: match -fixture node-modules: match -fixture mkdirp-bin: our engine ran the installed bin and created the directory -fixture esm-local: match -fixture esm-chalk: match -fixture esm-tla: match -fixture commander-cli: match -fixture anthropic-sdk: match -fixture tsc-cli: match -fixture yargs-cli: match -fixture child-process: match -fixture core-surface: match -fixture url-resolution: match -fixture ciphers-and-kdfs: match (identical ciphertext, tags and derived keys) -cross-engine crypto: what we seal real node opens, and what node seals we open -cross-engine signing: real node verifies our ECDSA and Ed25519 signatures, and we verify node's -fixture signing-shape: match -key types with no system implementation refuse by name (dsa, dh, x25519) -fixture fetch-streaming: match (chunks arrive as they are sent, like node) -fixture buffer-over-arraybuffer: match (wasm memory is shared, not copied) -fixture dot-require: match -cross-engine ECDH: the same shared secret on both sides, for every curve -fixture refusal-truth: every refusal names a reason and none is stale -fixture broadcast-channel: same-name fan-out, and a port queue that drains -fixture http-hangup: a dead connection errors and leaves the pool -fixture dns-lookup: match -fixture fetch-http: match -PHASE G: ALL PASS From dbd5d2de6daa9f351f20f82245672bad012b1cab Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 00:54:52 -0400 Subject: [PATCH 22/32] The alias stops standing on a real directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/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 --- .claude/npm-dev-brief.md | 6 ++++++ swift/Mouse/NodeEngine.swift | 36 ++++++++++++++++++++++++++-------- swift/Mouse/Shell.swift | 4 ++-- verify/lightningcss/main.swift | 4 ++-- verify/stackshape/main.swift | 4 ++-- verify/watchroot/main.swift | 5 ++++- 6 files changed, 44 insertions(+), 15 deletions(-) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index 3857dd3..86d0dd2 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -26,6 +26,12 @@ typed arrays instead of `String(body)`, and the two misreadings of source text that left svelte's `dev` a snapshot after all — prose inside a string read as code, and shorthand properties written one per line. +A full suite run (146 harnesses) found the one thing the loop had left behind: +the `/project` alias SHADOWED a real top-level directory of that name, and two +harnesses keep their packages in exactly such a directory. The alias now steps +aside to `/project-2` when the workspace already holds that name. Read +`engine.namedRoot`; do not assume the default. + ### Still open - ~~`error.stack` carries no `Name: message` header.~~ Done for every error JAVASCRIPT constructs — the error constructors are wrapped and the stack is diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index 871a1ed..098a701 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -214,19 +214,39 @@ final class NodeEngine: @unchecked Sendable { // as ERR_CLOSED_SERVER. No other platform hits this because no real project lives at // the filesystem root. // - // Caveat, recorded rather than hidden: a workspace containing a top-level directory - // with this name is shadowed by the alias, since the prefix is matched before the root - // fallback. `/project/x` would reach `/x`, and the real one needs - // `/project/project/x`. + // The name is CHOSEN, not fixed, because the alias is matched before the root and would + // otherwise shadow a real directory of the same name: `/project/x` would reach + // `/x` and the true `/project/x` could not be addressed at all. + // That was written down as a caveat and it is not hypothetical — two harnesses here keep + // their packages in a directory called `project`, and both stopped finding them. So a + // workspace that already has that name gets `/project-2`, and both paths stay reachable. + // A local copy of the normaliser: the instance method would capture `self` here, and + // `namedRoot` is not assigned yet. + func normalizedPrefix(_ prefix: String) -> String { + var text = prefix.hasPrefix("/") ? prefix : "/" + prefix + while text.count > 1 && text.hasSuffix("/") { text.removeLast() } + return text + } var resolved: [(prefix: String, url: URL)] = - mounts.map { (prefix: normalizeMountPrefix($0.prefix), url: $0.url) } - resolved.append((prefix: Self.namedRoot, url: root)) + mounts.map { (prefix: normalizedPrefix($0.prefix), url: $0.url) } + let existing = Set((try? FileManager.default.contentsOfDirectory(atPath: root.path)) ?? []) + let claimed = Set(resolved.map(\.prefix)) + var name = Self.defaultNamedRoot + var attempt = 2 + while existing.contains(String(name.dropFirst())) || claimed.contains(name) { + name = "\(Self.defaultNamedRoot)-\(attempt)" + attempt += 1 + } + self.namedRoot = name + resolved.append((prefix: name, url: root)) self.mounts = resolved.sorted { $0.prefix.count > $1.prefix.count } } /// The workspace's named spelling — what a program gets as its cwd, so its own root has a - /// real basename. See the note in `init`. - static let namedRoot = "/project" + /// real basename. Per engine, because it steps aside for a real directory of the same name; + /// read it from the engine rather than assuming the default. See the note in `init`. + let namedRoot: String + static let defaultNamedRoot = "/project" /// Attach the terminal after init — the host program can't hand closures over itself /// to its own initializer. Must happen before `run`. diff --git a/swift/Mouse/Shell.swift b/swift/Mouse/Shell.swift index a8ef770..bb0bcfb 100644 --- a/swift/Mouse/Shell.swift +++ b/swift/Mouse/Shell.swift @@ -1471,7 +1471,7 @@ final class MouseShell { let engine = NodeEngine(root: context.root, env: env, shell: bridge, mounts: runtimeMounts) let program = NodeProgram( title: title, source: source, path: path, argv: ["node"] + (isEval ? [] : [path]) + args, - cwd: NodeEngine.namedRoot + (cwd.isEmpty ? "" : "/" + cwd), engine: engine, + cwd: engine.namedRoot + (cwd.isEmpty ? "" : "/" + cwd), engine: engine, transcript: { line, isError in context.emit(Output(text: line, isError: isError)) }, clearTranscript: { context.clear() }, onExit: { context.reloadTree() }) @@ -1482,7 +1482,7 @@ final class MouseShell { defer { nodeDepth -= 1 } let engine = NodeEngine(root: context.root, env: env, shell: bridge, mounts: runtimeMounts) let result = await engine.run(source: source, path: path, argv: ["node"] + (isEval ? [] : [path]) + args, - cwd: NodeEngine.namedRoot + (cwd.isEmpty ? "" : "/" + cwd), stdin: stdin) + cwd: engine.namedRoot + (cwd.isEmpty ? "" : "/" + cwd), stdin: stdin) context.reloadTree() // scripts write files return IO(out: result.out, err: result.err, status: result.status) } diff --git a/verify/lightningcss/main.swift b/verify/lightningcss/main.swift index b577291..b9be9d9 100644 --- a/verify/lightningcss/main.swift +++ b/verify/lightningcss/main.swift @@ -58,8 +58,8 @@ console.log('COMPOSE ' + typeof css.composeVisitors); console.log('BUNDLE ' + typeof css.bundle); """ let engine = NodeEngine(root: scratch, env: ["PATH": "/usr/bin"]) -let run = await engine.run(source: script, path: NodeEngine.namedRoot + "/probe.js", - argv: ["node", "probe.js"], cwd: NodeEngine.namedRoot, stdin: "") +let run = await engine.run(source: script, path: engine.namedRoot + "/probe.js", + argv: ["node", "probe.js"], cwd: engine.namedRoot, stdin: "") let lines = run.out.split(separator: "\n").map(String.init) func line(_ prefix: String) -> String? { lines.first { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) } diff --git a/verify/stackshape/main.swift b/verify/stackshape/main.swift index e5bcb45..36c63e0 100644 --- a/verify/stackshape/main.swift +++ b/verify/stackshape/main.swift @@ -69,8 +69,8 @@ let nodeText = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), process.waitUntilExit() let engine = NodeEngine(root: base, env: ["PATH": "/usr/bin"]) -let mine = await engine.run(source: script, path: NodeEngine.namedRoot + "/probe.js", - argv: ["node", "probe.js"], cwd: NodeEngine.namedRoot, stdin: "") +let mine = await engine.run(source: script, path: engine.namedRoot + "/probe.js", + argv: ["node", "probe.js"], cwd: engine.namedRoot, stdin: "") func lines(_ text: String) -> [String] { text.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/verify/watchroot/main.swift b/verify/watchroot/main.swift index 7253ee2..e98c33d 100644 --- a/verify/watchroot/main.swift +++ b/verify/watchroot/main.swift @@ -96,7 +96,10 @@ let cwd = field("CWD ") ?? "(none)" // The root a program starts at must have a parent and a basename. "/" has neither, and that is // the entire bug: `dirname("/")` is "/" and `basename("/")` is "". check(cwd != "/", "a program does not start at \"/\" — it starts at \(cwd)") -check(cwd == NodeEngine.namedRoot, "the launch cwd is the named root \(NodeEngine.namedRoot), got \(cwd)") +// Asked of an engine over this same workspace rather than assumed: the name steps aside when the +// workspace already holds a directory of that name, so there is no single right answer to hardcode. +let expectedRoot = NodeEngine(root: base, env: [:]).namedRoot +check(cwd == expectedRoot, "the launch cwd is the named root \(expectedRoot), got \(cwd)") let events = field("EVENTS ") ?? "(no output)" // The storm reported every file in the project as deleted. Nothing was deleted here. From efa0dfe9d9db2d6bd49dc7ecdbe771646cf41188 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 01:25:26 -0400 Subject: [PATCH 23/32] Reconciled: the whole suite, against the code that ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 4 +++- STATUS.md | 14 ++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index 86d0dd2..bcbd97f 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -26,7 +26,9 @@ typed arrays instead of `String(body)`, and the two misreadings of source text that left svelte's `dev` a snapshot after all — prose inside a string read as code, and shorthand properties written one per line. -A full suite run (146 harnesses) found the one thing the loop had left behind: +The whole suite is green against the final code: **141 assertions passed, 0 +failed**, plus the five harnesses `verify.sh` declares investigations and does +not count. The run before it found the one thing the loop had left behind: the `/project` alias SHADOWED a real top-level directory of that name, and two harnesses keep their packages in exactly such a directory. The alias now steps aside to `/project-2` when the workspace already holds that name. Read diff --git a/STATUS.md b/STATUS.md index e9a26c8..be786f4 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,9 +1,15 @@ # STATUS.md — the verified state of Mouse -Last reconciled: 2026-07-31, at commit 953dd98 (end of the phase-G run, -27ed75b..953dd98, 184 commits). This file is the one place that says where -every phase stands, with the evidence named. When a phase's state changes, -this file moves with it — same rule as every other doc here. +Last reconciled: 2026-08-12, at commit dbd5d2d (the `npm run dev` run, +d27ffdd..dbd5d2d), by running the whole suite rather than trusting the last +green one: **141 assertions passed, 0 failed, 0 build-broken**, plus the five +harnesses `verify.sh` declares investigations and does not count — `breadth`, +`glob`, `reachable`, `shapes`, `vitestrun`. Before that, 2026-07-31 at 953dd98 +(end of the phase-G run, 27ed75b..953dd98, 184 commits). + +This file is the one place that says where every phase stands, with the evidence +named. When a phase's state changes, this file moves with it — same rule as +every other doc here. The letter phases are subsystems, not a sequence. Build order was dependency-driven: T → A → F → G, with D falling out of G. compile.md From 1232471b30073d46e093f4ac5025ae09609c9faa Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 01:28:38 -0400 Subject: [PATCH 24/32] The watcher, exercised rather than inferred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index bcbd97f..3a8b7c1 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -12,6 +12,17 @@ compiled), `hmr` (the watcher's edit pushed down a real WebSocket), `firstrun` (`npm create vite` scaffolds, installs and serves, end to end), `vite`, `npmrun` and `tscwatch`. +The WATCHER — the subsystem the storm lived in — was finally exercised on the +device rather than inferred from a quiet log. Appending a line to +`src/routes/+page.svelte` in the running workspace: + + ~3s later curl returns 33133 bytes instead of 33105, marker present + terminal 1 line: `[vite] (ssr) page reload src/routes/+page.svelte` + reverted ~3s later, back to 33105 bytes, marker gone + +One reload, naming the file that actually changed. Not a restart, not an unlink, +not a storm. That is the original bug's exact inverse, measured where it lived. + The scan no longer fails. The one remaining sighting of "Failed to run dependency scan" came from a harness that called `server.close()` while the scan was still in flight — vite's own `ERR_CLOSED_SERVER`, correctly reported. From a7230fd6feb0a63725f159f40a2b3e51c686e8e9 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 01:31:20 -0400 Subject: [PATCH 25/32] Tailwind 3 works, and saying so is the useful half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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: '
' }]` — 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 --- .claude/npm-dev-brief.md | 7 ++- STATUS.md | 2 +- verify/tailwind/main.swift | 91 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 verify/tailwind/main.swift diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index 3a8b7c1..f3c4b17 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -66,7 +66,12 @@ aside to `/project-2` when the workspace already holds that name. Read during a busy sweep looked exactly like a regression, and was bisected across five commits before two clean re-runs showed the same binary passing. When a gate fails once under load, re-run it before believing it. -- **Tailwind 4 cannot run here, and the reason is not ours to fix.** Restoring +- **Tailwind 4 cannot run here; tailwind 3 can, and does.** Version 3 has no + native half — scanner and compiler are both JavaScript — and + `verify/tailwind` pins it: content scanning, plain utilities, a `hover:` + variant, a `md:` breakpoint reaching `@media`, an arbitrary `w-[37px]`, and an + unused class correctly absent. A user who wants tailwind on a phone today + wants 3. The rest of this entry is about 4, and is not ours to fix. Restoring `tailwindcss()` to `local__test-2/vite.config.ts` gets past lightningcss now (substituted, gated by `verify/lightningcss`) and then stops at `@tailwindcss/oxide`. Its wasi build declares SHARED memory, and JSC answers diff --git a/STATUS.md b/STATUS.md index be786f4..a9c172f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -22,7 +22,7 @@ numbers some of the same ground differently; the mapping is noted per row. | **T** — terminal screen | VT100/xterm grid, ANSI parser, TUI hosting, keyboard encoding | **Done** | `swift/Mouse/TerminalScreen.swift`, `TerminalWidth.swift`, `TerminalPrograms.swift`. Gated: screen corpus, pyte cross-check, wide-char/alt-screen/DECCKM/bracketed-paste harnesses (`verify/tty`, `altscreen`, `widetui`, `inkwide`) | | **A** — shell language | msh's POSIX-subset grammar: control flow, `$()`, functions, redirects | **Done** | `swift/Mouse/ShellLanguage.swift`. Gated against real `/bin/sh` on the 25-script corpus (`verify/shell`) | | **F** — package manager | Real npm registry, semver, integrity, lockfiles; `npm run`/`create`/`npx`; native-binary → wasm-build substitution | **Done** | `swift/Mouse/PackageManager.swift`. Gated: semver corpus vs pnpm, `verify/pkg`, `firstrun` (scaffold→install→dev end to end) | -| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. 146-harness suite (`verify/`), fixtures byte-identical to real node v22. Added since: `lightningcss` (the native CSS transformer, substituted with its authors' wasm build), `watchroot` (a watcher rooted at the project root, which is where the restart storm lived), `stackshape` (error.stack carrying its message, the way V8 writes it) | +| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. 146-harness suite (`verify/`), fixtures byte-identical to real node v22. Added since: `lightningcss` (the native CSS transformer, substituted with its authors' wasm build), `watchroot` (a watcher rooted at the project root, which is where the restart storm lived), `stackshape` (error.stack carrying its message, the way V8 writes it), `tailwind` (version 3 compiles here; version 4 cannot, and the wall is Apple's) | | **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). A real SvelteKit project runs `npm run dev` on the device: starts once, pre-bundles its 20 dependencies, and answers a curl from the Mac with HTTP 200 and the rendered 33 kB page. Remaining piece is the Preview surface (phase C) | | **B** — WebView JIT | Move JS/wasm execution into WKWebView for JIT speed | **Not started — optional** | Measured: everything runs interpreted; the JIT buys speed, not capability (system.md:2094). No longer a prerequisite for anything | | **C** — Preview container | In-app viewing surface for what dev servers serve; LAN hosting | **Not started** | The server half works (vite serves clients outside the app — gated in `verify/devserver`); no in-app viewer exists | diff --git a/verify/tailwind/main.swift b/verify/tailwind/main.swift new file mode 100644 index 0000000..06cb132 --- /dev/null +++ b/verify/tailwind/main.swift @@ -0,0 +1,91 @@ +import Foundation +setvbuf(stdout, nil, _IONBF, 0) +// Tailwind, and WHICH tailwind, because the answer differs by major version and the difference +// is not ours to fix. +// +// Tailwind 4 cannot run here. `@tailwindcss/oxide` is a napi-rs binding whose wasi build declares +// SHARED memory, JavaScriptCore refuses to parse such a module ("shared memory is not enabled"), +// and the option gating it — `JSC::Options::useSharedArrayBuffer` — is restricted in Apple's +// build: settable by name, still false in `JSC_dumpOptions`. That is a platform wall, and it +// bounds every napi-rs wasi binding built with threads. +// +// Tailwind 3 has no native half at all — the scanner and the compiler are JavaScript — so it runs. +// A user who wants tailwind on a phone today wants version 3, and that is worth pinning rather +// than leaving in a comment, because "tailwind does not work" is a coarser claim than the truth +// and would send someone away who could have shipped. +var failures = 0 +func check(_ condition: Bool, _ label: String) { + if !condition { failures += 1; print(" FAIL: \(label)") } +} + +let base = FileManager.default.temporaryDirectory + .appendingPathComponent("tailwind-\(ProcessInfo.processInfo.processIdentifier)") +try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) +defer { try? FileManager.default.removeItem(at: base) } + +do { _ = try await PackageManager.install(requirements: ["tailwindcss": "^3.4.0"], into: base) } +catch { + print(" FAIL: install threw: \(error)") + print("TAILWIND: MISMATCH") + exit(1) +} + +// No native half to be missing: tailwind 3 ships no per-platform binary, which is the whole +// reason it runs where 4 does not. Asserted so a future version that grows one is noticed here. +let optional = (try? Data(contentsOf: base.appendingPathComponent("node_modules/tailwindcss/package.json"))) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + .flatMap { $0["optionalDependencies"] as? [String: Any] } ?? [:] +check(optional.keys.allSatisfy { !$0.contains("darwin") && !$0.contains("wasm") }, + "tailwind 3 lists no per-platform binary: \(optional.keys.sorted())") + +let script = """ +const tailwind = require('tailwindcss'); +const postcss = require('postcss'); +// Content scanning is the part people assume cannot work: tailwind reads the markup, decides +// which utilities exist, and emits only those. +const config = { + content: [{ raw: '
', + extension: 'html' }], + corePlugins: { preflight: false }, +}; +postcss([tailwind(config)]) + .process('@tailwind utilities;', { from: undefined }) + .then((result) => { + const css = result.css.replace(/\\s+/g, ' ').trim(); + console.log('LENGTH ' + css.length); + console.log('PLAIN ' + ['.mt-4', '.text-center', '.font-bold'].every(c => css.includes(c))); + console.log('ARBITRARY ' + css.includes('37px')); + console.log('VARIANT ' + (css.includes('hover\\\\:underline') || css.includes(':hover'))); + console.log('RESPONSIVE ' + css.includes('@media')); + console.log('UNUSED ' + css.includes('.mt-8')); + }) + .catch((e) => console.log('FAILED ' + (e && e.message || e))); +""" +let engine = NodeEngine(root: base, env: ["PATH": "/usr/bin"]) +let run = await engine.run(source: script, path: engine.namedRoot + "/probe.js", + argv: ["node", "probe.js"], cwd: engine.namedRoot, stdin: "") +let lines = run.out.split(separator: "\n").map(String.init) +func field(_ prefix: String) -> String? { + lines.first { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) } +} +if let failure = field("FAILED ") { + failures += 1 + print(" FAIL: tailwind threw: \(failure)") +} else { + check(field("PLAIN ") == "true", "the plain utilities compiled") + check(field("ARBITRARY ") == "true", "an arbitrary value, w-[37px], compiled") + check(field("VARIANT ") == "true", "a hover: variant compiled") + check(field("RESPONSIVE ") == "true", "a md: variant produced a @media rule") + // The scanner is doing its job rather than emitting everything. + check(field("UNUSED ") == "false", "a class the markup never used was NOT emitted") + check((Int(field("LENGTH ") ?? "0") ?? 0) > 100, "the sheet has real content: \(field("LENGTH ") ?? "0") bytes") +} +if run.out.isEmpty { failures += 1; print(" FAIL: no output — \(run.err.prefix(400))") } + +if failures == 0 { + print("TAILWIND: 7 checks — tailwind 3 scans markup and compiles utilities, variants and " + + "arbitrary values on this engine (4 cannot: oxide's wasi build needs shared memory) — MATCH") +} else { + print("TAILWIND: \(failures) of 7 checks failed — MISMATCH") + exit(1) +} From 4b02ca7a79a39006c8fb0a0f878062841e8c164a Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 01:31:34 -0400 Subject: [PATCH 26/32] The harness count follows the harnesses 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 --- STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/STATUS.md b/STATUS.md index a9c172f..7ea8f90 100644 --- a/STATUS.md +++ b/STATUS.md @@ -22,7 +22,7 @@ numbers some of the same ground differently; the mapping is noted per row. | **T** — terminal screen | VT100/xterm grid, ANSI parser, TUI hosting, keyboard encoding | **Done** | `swift/Mouse/TerminalScreen.swift`, `TerminalWidth.swift`, `TerminalPrograms.swift`. Gated: screen corpus, pyte cross-check, wide-char/alt-screen/DECCKM/bracketed-paste harnesses (`verify/tty`, `altscreen`, `widetui`, `inkwide`) | | **A** — shell language | msh's POSIX-subset grammar: control flow, `$()`, functions, redirects | **Done** | `swift/Mouse/ShellLanguage.swift`. Gated against real `/bin/sh` on the 25-script corpus (`verify/shell`) | | **F** — package manager | Real npm registry, semver, integrity, lockfiles; `npm run`/`create`/`npx`; native-binary → wasm-build substitution | **Done** | `swift/Mouse/PackageManager.swift`. Gated: semver corpus vs pnpm, `verify/pkg`, `firstrun` (scaffold→install→dev end to end) | -| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. 146-harness suite (`verify/`), fixtures byte-identical to real node v22. Added since: `lightningcss` (the native CSS transformer, substituted with its authors' wasm build), `watchroot` (a watcher rooted at the project root, which is where the restart storm lived), `stackshape` (error.stack carrying its message, the way V8 writes it), `tailwind` (version 3 compiles here; version 4 cannot, and the wall is Apple's) | +| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. 147-harness suite (`verify/`), fixtures byte-identical to real node v22. Added since: `lightningcss` (the native CSS transformer, substituted with its authors' wasm build), `watchroot` (a watcher rooted at the project root, which is where the restart storm lived), `stackshape` (error.stack carrying its message, the way V8 writes it), `tailwind` (version 3 compiles here; version 4 cannot, and the wall is Apple's) | | **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). A real SvelteKit project runs `npm run dev` on the device: starts once, pre-bundles its 20 dependencies, and answers a curl from the Mac with HTTP 200 and the rendered 33 kB page. Remaining piece is the Preview surface (phase C) | | **B** — WebView JIT | Move JS/wasm execution into WKWebView for JIT speed | **Not started — optional** | Measured: everything runs interpreted; the JIT buys speed, not capability (system.md:2094). No longer a prerequisite for anything | | **C** — Preview container | In-app viewing surface for what dev servers serve; LAN hosting | **Not started** | The server half works (vite serves clients outside the app — gated in `verify/devserver`); no in-app viewer exists | From cba4f1e7506922482510a2d1013834550e92b0bf Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 01:35:12 -0400 Subject: [PATCH 27/32] The brief says what is true now, in 159 lines instead of 438 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 531 ++++++++++----------------------------- 1 file changed, 126 insertions(+), 405 deletions(-) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index f3c4b17..c56faa8 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -6,433 +6,154 @@ curl http://localhost:5173/ HTTP 200, 33105 bytes, the rendered app node_modules/.vite/deps 20 dependencies pre-bundled -Not a SvelteKit special case, which the brief asks for explicitly. Green on the -same engine sources: `reactdev` (vite serves .tsx with types erased and JSX -compiled), `hmr` (the watcher's edit pushed down a real WebSocket), `firstrun` -(`npm create vite` scaffolds, installs and serves, end to end), `vite`, `npmrun` -and `tscwatch`. +The watcher was exercised rather than inferred, 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 in +it, the terminal printed exactly one line — +`[vite] (ssr) page reload src/routes/+page.svelte` — and reverting put it back. +One reload, naming the file that changed. That is the original bug's inverse. + +Not a SvelteKit special case, which this brief asks for explicitly. Green on the +same engine: `reactdev` (vite serves .tsx with types erased and JSX compiled), +`hmr` (an edit pushed down a real WebSocket), `firstrun` (`npm create vite` +scaffolds, installs and serves, end to end), `vite`, `npmrun`, `tscwatch`. + +Whole suite at the shipping code: **141 assertions passed, 0 failed**, plus the +five harnesses `verify.sh` declares investigations and does not count. + +### The fixes, in the order they were found + +Each verified on the simulator, each with its evidence in its commit message. + +1. **The restart storm** — the project sat at `/`; see the root cause below. A + program now starts at a named root, so the path it watches has a real parent + and a real basename. +2. **`import` as a method name** — `obj.import(...)` was read as an import. +3. **ESM named imports were a snapshot, not a live binding**, so a module that + exports `let x` and assigns it later stayed frozen at `undefined`. +4. **`Request`/`Response` bodies from a Uint8Array** went through `String(body)` + and arrived as comma-separated character codes, so every SSR response was + digits (`6328744`). +5. **Svelte's compiler ran half in dev mode** (`e45bb6e`): the live-binding + rewrite skipped `dev` because `svelte.dev` inside a thrown error's URL read as + a parameter, and it mis-rewrote shorthand properties written one per line. +6. **lightningcss on wasm, and `file:/x` is the same URL as `file:///x`** + (`c1e5b16`). +7. **`error.stack` had no `Name: message` header** (`7dfec54`), so every tool + logging a stack printed frames and no reason. +8. **`listen()` with no host bound IPv4-only `0.0.0.0`** (`213226a`) where node + binds dual-stack `::`, so a program could not reach its own server by name. +9. **The `/project` alias shadowed a real directory of that name** (`dbd5d2d`). + +## Still open -The WATCHER — the subsystem the storm lived in — was finally exercised on the -device rather than inferred from a quiet log. Appending a line to -`src/routes/+page.svelte` in the running workspace: - - ~3s later curl returns 33133 bytes instead of 33105, marker present - terminal 1 line: `[vite] (ssr) page reload src/routes/+page.svelte` - reverted ~3s later, back to 33105 bytes, marker gone - -One reload, naming the file that actually changed. Not a restart, not an unlink, -not a storm. That is the original bug's exact inverse, measured where it lived. - -The scan no longer fails. The one remaining sighting of "Failed to run -dependency scan" came from a harness that called `server.close()` while the -scan was still in flight — vite's own `ERR_CLOSED_SERVER`, correctly reported. -Left open, the scan completes and writes its metadata. - -The five fixes, in the order they were found, each verified on the simulator: -the restart storm (a project rooted at `/`, so chokidar filed an empty-named -child in the root's own record and tore the tree down every three seconds — -launch cwd is now `/project`), `import` as a method name, ESM named imports as -live bindings rather than snapshots, `Request`/`Response` bodies built from -typed arrays instead of `String(body)`, and the two misreadings of source text -that left svelte's `dev` a snapshot after all — prose inside a string read as -code, and shorthand properties written one per line. - -The whole suite is green against the final code: **141 assertions passed, 0 -failed**, plus the five harnesses `verify.sh` declares investigations and does -not count. The run before it found the one thing the loop had left behind: -the `/project` alias SHADOWED a real top-level directory of that name, and two -harnesses keep their packages in exactly such a directory. The alias now steps -aside to `/project-2` when the workspace already holds that name. Read -`engine.namedRoot`; do not assume the default. - -### Still open -- ~~`error.stack` carries no `Name: message` header.~~ Done for every error - JAVASCRIPT constructs — the error constructors are wrapped and the stack is - V8-shaped, gated by `verify/stackshape` against real node. An error the ENGINE - throws still reads as bare frames through `.stack`, because JSC materialises - `stack` during construction and no hook sees that; console and util.inspect - rebuild the header for those. Worth knowing if a message ever goes missing - again: the remaining hole is engine-thrown TypeErrors read via `.stack`. -- ~~`verify/nodejs` has a pre-existing `event-sequences` mismatch.~~ Fixed, and - the cause was worth having: `listen()` with no host bound `0.0.0.0`, IPv4 - only, where node binds `::` with IPV6_V6ONLY off. `localhost` resolves to - `::1` first on this platform, so a program that called its own server BY NAME - was refused — `http.get({ port })`, which is how node's own docs write it. - Sockets given an explicit '127.0.0.1' always worked, which is exactly why it - hid for so long. -- `verify/devserver` was FLAKY and is no longer: it slept a fixed six seconds - for vite to boot, which is enough alone and not enough beside a parallel - build. It now polls for the server. Worth remembering how it read: a red run - during a busy sweep looked exactly like a regression, and was bisected across - five commits before two clean re-runs showed the same binary passing. When a - gate fails once under load, re-run it before believing it. - **Tailwind 4 cannot run here; tailwind 3 can, and does.** Version 3 has no native half — scanner and compiler are both JavaScript — and `verify/tailwind` pins it: content scanning, plain utilities, a `hover:` variant, a `md:` breakpoint reaching `@media`, an arbitrary `w-[37px]`, and an unused class correctly absent. A user who wants tailwind on a phone today - wants 3. The rest of this entry is about 4, and is not ours to fix. Restoring - `tailwindcss()` to `local__test-2/vite.config.ts` gets past lightningcss now - (substituted, gated by `verify/lightningcss`) and then stops at - `@tailwindcss/oxide`. Its wasi build declares SHARED memory, and JSC answers - `WebAssembly.Module doesn't parse at byte 398: shared memory is not enabled`. - Measured, so nobody re-derives it: `new WebAssembly.Memory({shared: true})` - succeeds, but a module whose memory section carries the shared flag does not - parse. The gate is `JSC::Options::useSharedArrayBuffer`, and it is RESTRICTED - in Apple's build — `JSC_useSharedArrayBuffer=true` in the environment is - accepted as a name (a bogus name next to it is rejected, so the parse - happened) and `JSC_dumpOptions=1` still prints - `useSharedArrayBuffer=false (default: false)`. So the config keeps tailwind - out, and the project runs. This also bounds every other napi-rs wasi binding - built with threads. - -## History — blocker at iteration 9: the PINNED ESM divergence - -Fixed so far, in order, each verified on the simulator: the restart storm -(project rooted at `/`, commit d27265e), and `import` as a method name -(commit d2cd024). SSR now reaches svelte's compiler, which throws at -`1-parse/state/element.js:946`, i.e. `start: locator(start)`. - -`locator` is undefined, and the reason is our own known divergence: - - svelte/src/compiler/state.js:50 export let locator; // undefined at import time - svelte/src/compiler/state.js:59 locator = (i) => { … }; // assigned LATER - …/1-parse/state/element.js:17 import { locator } from '../../../state.js'; - -Real ESM named imports are LIVE bindings, so `locator` becomes the function. -Ours are a SNAPSHOT — the transpiler emits -`const locator = __esmBinding(ns, 'locator')` — so it is frozen at `undefined` -forever. This is precisely the divergence `verify/esmgrammar` records as -"1 pinned divergence (named imports are a snapshot; the namespace form is live)". -It is not a cosmetic gap: it breaks Svelte, and it will break any library that -exports a `let` and assigns it during initialisation. - -### What the fix requires -Named imports must read through the namespace at USE time, not at import time. -The transpiler currently emits one `const` per binding; making it live means -rewriting every REFERENCE to `ns.name`, which needs identifier-level rewriting -with scope awareness (shadowing, object keys, property access after a dot, -strings/comments already masked). That is a real piece of work in -`transpileESM` and it must be gated by extending verify/esmgrammar with a -live-binding case that FAILS today: - - // mod.js - export let value = 'before'; - export function set() { value = 'after'; } - // main.js - import { value, set } from './mod.js'; - set(); - console.log(value); // real node: 'after' ours today: 'before' - -Do that gate FIRST so the fix has something to turn green. - -The loop prompt points here. Read this file every iteration. + wants 3. + + Version 4 is a platform wall, not our bug. `@tailwindcss/oxide`'s wasi build + declares SHARED memory and JSC answers `WebAssembly.Module doesn't parse at + byte 398: shared memory is not enabled`. Measured, so nobody re-derives it: + `new WebAssembly.Memory({shared: true})` succeeds, but a module whose memory + section carries the shared flag does not parse. The gate is + `JSC::Options::useSharedArrayBuffer`, RESTRICTED in Apple's build — + `JSC_useSharedArrayBuffer=true` is accepted as a name (a bogus name beside it + is rejected, so the parse happened) and `JSC_dumpOptions=1` still prints + `useSharedArrayBuffer=false (default: false)`. This bounds every napi-rs wasi + binding built with threads. `tailwindcss()` is therefore kept out of + `local__test-2`'s `vite.config.ts`, and the project runs. + +- **An error the ENGINE throws still has no header when read through `.stack`.** + JSC materialises `stack` during construction, on its own errors as much as + ours, and no hook sees that moment. Errors JavaScript constructs are covered; + console and util.inspect rebuild the header for the rest. ## The target, concretely -In the simulator workspace `local__test-2` (a SvelteKit project created by `npx sv create`), -`npm run dev` must: - -1. start **once** — no restart loop, -2. not print `Failed to run dependency scan`, -3. print its URL, and -4. answer `curl http://localhost:/` **from the Mac** with the app's HTML — - not a 500, not a refused connection. - -The fix belongs in the ENGINE (`swift/`). Do not "fix" it by editing the user's project -or `node_modules`. Temporary instrumentation of `node_modules` is allowed for diagnosis -and MUST be reverted before the iteration ends. +In the simulator workspace `local__test-2` (a SvelteKit project created by +`npx sv create`), `npm run dev` must start **once** — no restart loop; not print +`Failed to run dependency scan`; print its URL; and answer +`curl http://localhost:/` **from the Mac** with the app's HTML. -It must be general — React/Vite and any other node dev server, not a SvelteKit special case. +The fix belongs in the ENGINE (`swift/`). Do not "fix" it by editing the user's +project or `node_modules`. Temporary instrumentation of `node_modules` is +allowed for diagnosis and MUST be reverted before the iteration ends. It must be +general — React/Vite and any other node dev server. -## Root cause chain — measured, do not re-derive +## Root cause of the storm — measured, do not re-derive -1. Vite prints `vite.config.ts changed, restarting server...` every ~2-3 seconds, forever. - The config's **mtime is constant** across restarts — the file genuinely never changes. -2. Each restart **aborts the in-flight esbuild dependency scan**. The captured scan error is - `[ERROR] The server is being restarted or closed. Request is outdated`, - `code: ERR_CLOSED_SERVER`, plugin `vite:dep-scan`, on @sveltejs/kit runtime files. - So "dependencies are not found" is a CONSEQUENCE of the restart loop, not a second bug. - Fix the loop and the scan should complete. -3. Vite's trigger is `handleHMRUpdate` in `node_modules/vite/dist/node/chunks/config.js`: - it restarts when the changed file equals `config.configFile`, is in - `config.configFileDependencies`, or is an env file. The printed name is exactly - `vite.config.ts`, so the file it saw IS the config path. - -## Already ruled out by device probes — do not repeat - -- esbuild core works: bundled a module, 38 bytes out. -- esbuild **plugin callbacks** over the pipe work (onResolve/onLoad): `PLUGIN OK bytes=61`. -- `fs.appendFileSync` creates a missing file correctly. -- A raw `fs.watch` on the config plus `fs.watch('.')` reported **zero** events while the - config was READ and while a temp file was written and deleted under - `node_modules/.vite-temp`. The naive "reading the config fires an event" theory is FALSE. -- The socket leak is already fixed (3607 leaked listeners, now bounded) — commit b1133b6. - -## ITERATION 1 FINDINGS — the theory in "Chase this first" below is now DEAD - -Two measurements, both on the simulator with the real project: - -1. `NodeWatch.emit` was instrumented with NSLog (every event, path + kind). During a FULL vite - restart loop it logged **ZERO events**. Our fs.watch is not firing at all — spurious events - are not the problem. -2. A chokidar probe using vite's own options (`chokidar.watch('.', { ignored: [...], - ignoreInitial: true })`, chokidar 4.0.3, which uses the callback `fs_watch`, not fsevents) - emitted **only `ready`**. It MISSED a control write of `canary.txt` directly into the watched - root. So watching is broken in the MISSING-events direction, end to end. - -So the restart trigger is NOT the file watcher, and there is a SECOND, separate bug: a new file -in a watched directory produces no event, which also means HMR cannot work. - -## ROOT CAUSE FOUND (iteration 4) — the project sits at `/`, and chokidar cannot cope - -Instrumenting the INLINED chokidar's removal diff produced the answer in one run: +A project rooted at `/` breaks file watching, spectacularly. chokidar records a +directory under its parent: `_getWatchedDir(dirname(dir)).add(basename(dir))`. +For `dir === "/"`, POSIX gives dirname `/` and basename `""` — node agrees, our +path module is right — so it files an EMPTY-NAMED child inside the root's own +record. The next `_handleRead('/')` holds the real entries and never contains +`""`, so the diff removes it, `_remove('/', '')` resolves back to the root, and +the whole tracked tree is torn down: an `unlink` for every file in the project, +`vite.config.ts` included. Vite restarts, builds a fresh watcher, and does it +again about three seconds later. Captured in one run as: REMOVE dir=/ item= currentSize=14 prevN=3 -The item being removed is the EMPTY STRING, and `currentSize=14` proves the directory read is -healthy. The mechanism: - -- Our workspace is mounted so the project root is literally `/`. -- chokidar's `_handleDir` does `_getWatchedDir(dirname(dir)).add(basename(dir))`. -- For `dir === '/'`, POSIX says `dirname('/') === '/'` and `basename('/') === ''` (node agrees; - our path module is CORRECT here — this is not a path bug). -- So chokidar adds an empty-string child INTO THE ROOT'S OWN watched-dir record. -- On the next `_handleRead('/')`, `current` holds the real 14-15 entries and never contains `''`, - so the diff removes `''` -> `_remove('/', '')` -> that resolves back to the root itself and - tears down the whole tracked tree, emitting `unlink` for EVERY file, including vite.config.ts. -- vite sees the config unlinked -> restarts -> new watcher -> same thing ~3s later. The loop. - -On a real machine a project is never at `/`, so `dirname` returns a real parent and `basename` a -real name; this edge case never fires. It is our virtual-filesystem layout that triggers it, so -the fix belongs in swift/ and is general — it will fix watching for React/Vite/anything, and HMR -along with it. - -### IMPLEMENTATION SCOPE, measured in iteration 5 - -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 `/`. vite takes that as `root`, chokidar watches -`/`, and the empty-basename bug fires. - -Changing the project's virtual root is NOT a one-line change: the whole engine is `/`-rooted — -the module 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 paths to the root. -Any rename shows up in all of those, which is why this needs a deliberate choice rather than a -quick edit. THE OWNER SHOULD PICK, because option A changes what users see. - - A. Root the workspace at a NAMED virtual path (e.g. `/project`) instead of `/`. - Correct and general — real projects are never at `/`, which is exactly why no other - platform hits this. Cost: user-visible paths change everywhere (prompt, traces, Viewer), - and every gate that asserts a `/`-rooted path needs updating. - B. Keep `/` as the workspace root, but make the path chokidar WATCHES have a real basename — - e.g. have `fs.realpathSync('/')` answer a named alias that also resolves, since chokidar - calls realpath on its watch target and uses the result for its bookkeeping. - Much smaller blast radius, but two names for one directory can desynchronise anything that - compares paths by string (vite computes root-relative paths constantly), so it must be - proven against the real dev server, not just a unit gate. - -**CORRECTION (iteration 6): OPTION B IS IMPOSSIBLE. Do not attempt it.** It assumed chokidar -uses the realpath of the watch target for its bookkeeping. It does not. In vite's inlined -chokidar, `_handleDir` reads: - - const parentDir$1 = this.fsw._getWatchedDir(sysPath$2.dirname(dir)); // 13263 - const tracked = parentDir$1.has(sysPath$2.basename(dir)); // 13264 - parentDir$1.add(sysPath$2.basename(dir)); // 13268 +The dependency-scan failure was downstream of that, never a second bug: the scan +is asynchronous, and vite reports `ERR_CLOSED_SERVER` when the plugin container +closes under it — `[plugin: vite:dep-scan] The server is being restarted or +closed. Request is outdated`. Fix the restarts and the scan completes. -`dir` — the path as passed in — drives dirname/basename; `realpath` is only consulted for the -`_symlinkPaths` check further down. So changing what realpath answers cannot remove the -empty-basename child. OPTION A IS THE ONLY FIX. +No other platform hits this, because no real project lives at the filesystem +root. Gated now by `verify/watchroot`, which drives the real shell because the +fix lives in the launch path; `verify/chokidar` watches a SUBDIRECTORY, which is +why it was green throughout. -### Option A, implementation plan (verified call sites) +**A named root was the only option, and the alternative is proven impossible.** +Making `fs.realpathSync('/')` answer a named alias cannot work: chokidar uses +`dir` — the path as passed in — for dirname/basename bookkeeping, and consults +realpath only for its symlink check. Do not attempt it. -1. `swift/Mouse/Shell.swift:1474` and `:1485` — both launch node with `cwd: "/" + cwd`. Change to - a NAMED root, e.g. `/project`, joined with msh's relative cwd: - `"/project" + (cwd.isEmpty ? "" : "/" + cwd)`. -2. `swift/Mouse/NodeEngine.swift:2492 realURL(_:)` is the single virtual->real resolver. It - already walks `mounts` with prefix matching, so the named root can be added as an ALIAS that - resolves to the same workspace URL as `/`. Both `/project/src/a.js` and `/src/a.js` then work, - which keeps existing behaviour intact while giving vite a root with a real basename. -3. Because vite derives every path from its own `root`, it will consistently see `/project/...` - and never mix the two spellings. The thing to watch for in verification is anything that - hands a program a `/`-rooted path AFTER startup (the Viewer's openFile hook, msh's own path - clamping) — those still speak `/`, which is fine as long as nothing string-compares the two. -4. Stack traces will read `mouse:///project/...`. Cosmetic, but docs and any gate pinning - `mouse:///` paths must move with it. +## Ruled out by device probes — do not repeat these -### The gate this needs, whichever option wins -A harness that watches a tree whose root is the VIRTUAL ROOT with chokidar, changes nothing, and -asserts that NO `unlink` is emitted within a few seconds. That is the exact shape of the bug and -nothing in verify/ covers it today (verify/chokidar watches a subdirectory, which is why it has -always passed). - -### The original two options (superseded by the scope note above) -1. **Give the workspace a non-root virtual path** (e.g. cwd `/project`, or a mount alias) so the - watched root has a real basename. The engine already has a `mounts` concept - (`NodeEngine(root:mounts:)`, `normalizeMountPrefix`, NodeEngine.swift ~193-201, 2476-2494) and - programs are launched with an explicit `cwd` (TerminalPrograms.swift ~341/397/441). Smallest - version: keep `/` working as it does today AND expose the same tree at a named path, then - launch node programs with that as cwd so tools resolve `root` to the named path. - Risk: user-visible paths change (msh prompt, Viewer, error traces) — check before committing. -2. **Make the root behave like a named directory to watchers only** — riskier and more magical; - prefer 1 unless 1 proves invasive. - -Whichever is chosen: gate it. A harness that watches a directory tree whose root is `/` and -asserts that NO unlink is emitted when nothing is deleted would have caught this, and there is -currently no such gate (verify/chokidar passes because it does not watch the virtual root). - -## ITERATION 3 — THE EVENT IS `unlink`, FOR EVERY FILE IN THE PROJECT - -Captured with a deep stack at vite's config-change branch, on a cleanly started server: - - === TYPE=delete FILE=/vite.config.ts - === TYPE=delete FILE=/static/robots.txt - === TYPE=delete FILE=/.gitignore - === TYPE=delete FILE=/.npmrc - ... - -chokidar is telling vite that EVERY FILE IN THE PROJECT WAS DELETED. Nothing was deleted. The -config's unlink is what restarts the server; the restart builds a new watcher, which shortly -declares everything deleted again. That is the loop, and it is an `unlink` storm, not an `add`. -(The iteration-1 guess of a leaked initial-scan `add` was WRONG — it is `delete`.) - -Directory reading is NOT the cause. Measured on device in that project: - - `fs.promises.readdir('.', { withFileTypes: true })` -> n=18, correct names, `isFile()` works. - - `readdirp('.', { depth: 0, type: 'all' })` -> n=18, every entry listed. -So the reads chokidar depends on are healthy. - -### Where the unlink must come from — instrument these two, in vite's INLINED chokidar -IMPORTANT: vite bundles its OWN chokidar inside `node_modules/vite/dist/node/chunks/config.js` -(see the `initialAdd && ignoreInitial` sites there). `node_modules/chokidar` (v4) is a DIFFERENT -copy used by other packages. Instrument the INLINED one — the unlink reaching vite comes from it. - -1. `_handleRead`'s directory diff: it builds `current` from a readdirp stream and then does - `previous.getChildren().filter(item => !current.has(item)).forEach(item => this.fsw._remove(...))`. - If `current` ends up EMPTY (stream yields nothing, or every entry is filtered out) while - `previous` holds all the files, every file is removed -> unlink for each. This is the prime - suspect and matches the symptom exactly. -2. `_handleFile`'s listener catch: `catch { this.fsw._remove(dirname, basename) }` when - `stat(file)` throws after a watch event. - -Decisive next step: add a logging line inside the inlined chokidar's `_remove` that appends -`item` plus `new Error().stack` (with `Error.stackTraceLimit = 60`) to a file using the module's -`fs` binding. That names which of the two paths fires, and the fix follows from it. - -## ITERATION 2 — A PROCESS TRAP THAT INVALIDATED MEASUREMENTS, AND A LEAK REFINEMENT - -READ THIS BEFORE MEASURING ANYTHING. - -1. **The dev server keeps running.** After `npm run dev`, the terminal is owned by the vite - PROGRAM. Anything typed afterwards goes to vite as KEYSTROKES, not to msh. Several - iteration-2 measurements were contaminated this way: `who.txt` kept showing output from a - previous run because the new `npm run dev` never actually started — the text went into the - running program. ALWAYS confirm the prompt reads `~ $` (kill with canc TWICE) before typing a - command, and confirm the command echoed on screen before trusting any result. - -2. **The listener leak is only HALF fixed.** b1133b6 releases the previous socket when the SAME - server object re-listens. But vite's restart builds a NEW http.Server each time, binds a new - port, and never closes the old one (its `createServerCloseFn` skips closing a server it never - saw emit 'listening'). So every restart still leaks one bound port: observed climbing - 5173 -> 5174 -> ... -> 6087 inside ONE app session. The leak is a SYMPTOM of the restart loop, - so fixing the loop mostly removes it — but a server that is garbage-collected without close - should still release its fd, and that is worth fixing on its own. - -3. Reading chokidar did not settle the trigger and cost a lot of cycles. Both chokidar copies - (v4.0.3 in node_modules, v3 inlined in vite's bundle) gate `add` on `initialAdd && - ignoreInitial`, and vite passes `ignoreInitial: true` (config.js:16779). The fsevents - `emitAdd` with its `forceAdd` bypass is NOT our path. So the leak of an `add` is still - unexplained by reading alone — MEASURE it: instrument chokidar's own `_emit` in node_modules - to append event+path+initialAdd to a file, with the dev server started cleanly per (1). - -### THE TRIGGER IS NOW IDENTIFIED (iteration 1, measured) - -A stack capture at vite's config-change branch names the caller: - - handleHMRUpdate config.js:26028 - onHMRUpdate config.js:25635 - config.js:25654 <-- onFileAddUnlink - -Line 25654 is the last line of `onFileAddUnlink`, i.e. `onHMRUpdate(isUnlink ? 'delete' : -'create', file)`. So vite is being told the config was **ADDED or UNLINKED**, not changed. The -captured file is `/vite.config.ts`, isConfig=true. - -Combine that with the two facts above — our fs.watch emits nothing, and chokidar misses a real -new file — and the coherent theory is: - -**chokidar's INITIAL SCAN is leaking 'add' events past `ignoreInitial`.** Every vite restart -builds a NEW chokidar watcher, whose initial scan re-announces `vite.config.ts` as an `add`, -which vite treats as a config change, which restarts the server, which builds another watcher. -That is the whole loop, and it also explains why live changes are missed: only the scan works, -the watch itself delivers nothing. - -Likely engine cause to test: chokidar decides when the initial scan is over by counting pending -async operations and then emitting `ready`. If our fs/readdir async completion ordering lets -chokidar flip to ready BEFORE the scan has finished, everything the scan finds afterwards is -emitted as a genuine `add` rather than being suppressed. Look at how our `fs.readdir`/`stat` -callbacks are scheduled relative to `process.nextTick`/microtasks, and at chokidar's -`_emitReady`/`_readyCount` bookkeeping in node_modules/chokidar/esm/handler.js. - -Cheap decisive probe for next iteration: chokidar.watch with `ignoreInitial: true` on a -directory, log every `add` WITH a timestamp and log `ready` too. If adds arrive AFTER ready, -the theory is confirmed and the fix is in our async fs scheduling (or in how fs.watch registers). - -### Superseded next step -Instrument `handleHMRUpdate` in node_modules/vite/dist/node/chunks/config.js to capture -`new Error().stack` at the moment it decides the config changed, and write it to a file with the -module's own `fs` binding — `fs.writeFileSync('/x.json', ...)` at that site WORKS (proven: it is -how the ERR_CLOSED_SERVER scan error was captured; `require('fs')` there does NOT work). That -stack names the caller and ends the guessing about what triggers the restart. - -Then, separately, isolate the missing-events bug: raw `fs.watch('.', cb)` + create a file -directly in that directory, and see whether the callback fires. If it does not, fix directory -watching in NodeWatch.swift and gate it. - -### Still-uncommitted instrumentation -`swift/Mouse/NodeWatch.swift` has a TEMP-DIAGNOSTIC NSLog block in `emit(...)`. Remove it before -committing anything. - -## Chase this first (SUPERSEDED — kept for the record) - -What makes chokidar (riding our `fs.watch`) tell vite the config changed or was added? -Instrument OUR side — `swift/Mouse/NodeWatch.swift` and the `fs.watch` bridge — logging every -event with its path and kind. Do not instrument vite again; that path has been exhausted. - -FIRST SUSPECT, found by reading NodeWatch.swift at the end of the last session and NOT yet -tested: the per-file watch registers eventMask `[.write, .rename, .delete, .attrib, .extend]` -(NodeWatch.swift ~line 133), and the handler treats EVERYTHING that is not delete/rename as a -content `change` (~line 150). So a bare NOTE_ATTRIB — an attribute touch, no content change — -is reported to node as `change`. node's own fs.watch does emit on some attribute changes, but -chokidar's atime guard normally absorbs them, and here that guard fails open (see below), so a -single stray NOTE_ATTRIB on the config is enough to restart vite forever. Test whether dropping -`.attrib` (and possibly `.extend`) from the FILE mask, or distinguishing attrib-only events from -writes, stops the loop — then check it does not break verify/chokidar and the fs.watch gates. - -Two facts that matter: -- vite creates a **new chokidar watcher on every restart**, and chokidar emits `add` and - `unlink` as well as `change`; vite treats add/unlink of the config as a restart too. -- On this filesystem **atime == mtime** for the config, which makes chokidar's - "ignore atime-only events" guard (`at <= mt` -> emit) fail OPEN. So ANY watcher event on - that file becomes a restart. - -## Other known state - -- `tailwindcss()` was removed from `local__test-2`'s `vite.config.ts` during debugging to get - past a lightningcss crash. Either restore it and make it work, or state plainly that it is a - separate blocker: lightningcss ships only native optionalDependencies with no `wasm32-wasi`, - and a `lightningcss` -> `lightningcss-wasm` entry in `PackageManager.wasmSubstitutes` is the - candidate fix. -- Branch: `fix/rolldown-wasi-and-causes`. +- esbuild core works, and its plugin callbacks over the pipe work. +- `fs.appendFileSync` creates a missing file correctly. +- A raw `fs.watch` on the config plus `fs.watch('.')` reported **zero** events + while the config was read and while a temp file was written and deleted under + `node_modules/.vite-temp`. "Reading the config fires an event" is FALSE. +- The event was never a leaked initial-scan `add`; it was `unlink`, and the + cause is the empty basename above. +- Directory reading was never at fault: `readdir` and `readdirp` both returned + all 18 entries with working `isFile()`. + +## Measuring on the device — read before trusting a number + +**The dev server keeps running.** After `npm run dev` the terminal is owned by +the vite PROGRAM: anything typed afterwards goes to vite as KEYSTROKES, not to +msh. Several measurements were contaminated this way, showing output from a +previous run because the new command never started. Confirm the prompt reads +`~ $` (kill with canc TWICE) and that the command echoed before trusting a +result. + +**Re-run a red gate before believing it.** Three conclusions in this loop were +wrong the first time and right after a re-run: "the dependency scan is a second +bug"; "devserver is a regression I caused" (a flake — it slept a fixed six +seconds for vite to boot, and now polls); and "event-sequences is pre-existing" +(asserted on a machine where five hung `verify_bin` processes were still +running). A flake and a regression read identically. ## Verification rule -Verify on the booted simulator (iPhone 16 Pro, 58A6B442-292A-4610-9DE8-500E7E8EBC74) EVERY -iteration: build, install, launch, drive the terminal, screenshot, and curl the port from the -Mac. A green build is not evidence. +Verify on the booted simulator (iPhone 16 Pro, +`58A6B442-292A-4610-9DE8-500E7E8EBC74`) EVERY iteration: build, install, launch, +drive the terminal, screenshot, and curl the port from the Mac. A green build is +not evidence. -Everything in AGENTS.md applies: gate what you fix, no leftover diagnostics, docs move with -behavior, commit at each boundary with the evidence in the message. +Everything in AGENTS.md applies: gate what you fix, no leftover diagnostics, +docs move with behavior, commit at each boundary with the evidence in the +message. -## Stop condition +## Stop condition — met -On the simulator: `npm run dev` in `local__test-2` starts once, prints its URL, does NOT print -`Failed to run dependency scan`, and a curl from the Mac to that port returns the app's HTML — -with a screenshot and the curl output as evidence. Plus gates green and `swift/` committed. +On the simulator, `npm run dev` in `local__test-2` starts once, prints its URL, +does not print `Failed to run dependency scan`, and a curl from the Mac returns +the app's HTML. Gates green, `swift/` committed. Branch: +`fix/rolldown-wasi-and-causes`. From 0873ca0ed78c2f9ed5080a063bac6e4314170333 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 01:50:57 -0400 Subject: [PATCH 28/32] Tapping the project's name goes back to the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- swift/Mouse/CarouselDeck.swift | 12 ++++++++++ swift/Mouse/TerminalSession.swift | 40 +++++++++++++++++++++---------- swift/Mouse/WorkspaceViews.swift | 16 +++++++++---- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/swift/Mouse/CarouselDeck.swift b/swift/Mouse/CarouselDeck.swift index 9339e24..3668df4 100644 --- a/swift/Mouse/CarouselDeck.swift +++ b/swift/Mouse/CarouselDeck.swift @@ -162,6 +162,18 @@ final class CarouselDeck { ringTerminal = session return session } + + /// Go back to the project picker. Everything the ring was holding belonged to the project it + /// is leaving: the terminal (and whatever it was running), the open file, the workspace + /// itself. Dropping the session without stopping it would strand a running dev server on its + /// port with no screen able to reach it. + @MainActor + func leaveWorkspace() { + ringTerminal?.stopForProjectChange() + ringTerminal = nil + openFilePath = nil + workspace = nil + } /// Extra divider height while a gap-label lesson needs room for its word. Animated model /// state (not derived at render time) so divider growth and the compensating lane re-fit /// share one transaction — the stack's total height never wavers. Set by the view layer. diff --git a/swift/Mouse/TerminalSession.swift b/swift/Mouse/TerminalSession.swift index ecd0607..a6eb49e 100644 --- a/swift/Mouse/TerminalSession.swift +++ b/swift/Mouse/TerminalSession.swift @@ -154,18 +154,7 @@ final class TerminalSession { // a double-tap re-armed instead of killing, which was measured, not guessed. if let at = interruptedAt, Date().timeIntervalSince(at) < 10 { interruptedAt = nil - append("killed \(program.title)", .error) - program.terminate() - // Termination flows through the program's own exit path (io.exit → the - // session's cleanup), which is what keeps engines from leaking. A program too - // far gone to run even that still must not keep the screen: reclaim it - // directly after a beat, if the same program is somehow still installed. - let held = ObjectIdentifier(program as AnyObject) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in - guard let self, let current = self.program, - ObjectIdentifier(current as AnyObject) == held else { return } - self.programExited() - } + forceStop(program) return } interruptedAt = Date() @@ -177,6 +166,33 @@ final class TerminalSession { runningTask?.cancel() } + /// Take the terminal back without asking the program first. The program does not get a vote + /// here, unlike `interrupt`'s first press: this is the close button. + private func forceStop(_ program: TerminalProgram) { + append("killed \(program.title)", .error) + program.terminate() + // Termination flows through the program's own exit path (io.exit → the session's + // cleanup), which is what keeps engines from leaking. A program too far gone to run even + // that still must not keep the screen: reclaim it directly after a beat, if the same + // program is somehow still installed. + let held = ObjectIdentifier(program as AnyObject) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + guard let self, let current = self.program, + ObjectIdentifier(current as AnyObject) == held else { return } + self.programExited() + } + } + + /// Leaving the project takes its terminal with it. A program started here belongs to the + /// workspace that is going away — a dev server left running would hold its port with nothing + /// on screen able to reach it, which is the "a running program could not be stopped at all" + /// trap in a new costume. So it is stopped outright rather than sent a ^C it may ignore; + /// vite, for one, treats ^C as a keystroke and keeps serving. + func stopForProjectChange() { + if let program { forceStop(program) } + runningTask?.cancel() + } + // MARK: - Full-screen programs /// Adopt a program: size the screen, hand it its IO, and give it the keyboard. Refused diff --git a/swift/Mouse/WorkspaceViews.swift b/swift/Mouse/WorkspaceViews.swift index 3116176..9319f97 100644 --- a/swift/Mouse/WorkspaceViews.swift +++ b/swift/Mouse/WorkspaceViews.swift @@ -167,10 +167,18 @@ private struct FileTreeView: View { var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - Text(workspace.repoFullName) - .font(.custom(AppFont.asciiName, size: 12)) - .opacity(0.55) - .padding(.bottom, 10) + // The project's name IS the way back to the picker — the same move as tapping a + // breadcrumb's root. It keeps the label's appearance because it is still the + // label; nothing here announces that it can be tapped. + Button { deck.leaveWorkspace() } label: { + Text(workspace.repoFullName) + .font(.custom(AppFont.asciiName, size: 12)) + .opacity(0.55) + .padding(.bottom, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) ForEach(visibleNodes()) { node in row(node) } From 755a1c1694dffc718e1391df9b14d40a3853fa15 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 01:54:56 -0400 Subject: [PATCH 29/32] The gate for leaving a project with something running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- STATUS.md | 2 +- verify/leaveproject/main.swift | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 verify/leaveproject/main.swift diff --git a/STATUS.md b/STATUS.md index 7ea8f90..f2c9a01 100644 --- a/STATUS.md +++ b/STATUS.md @@ -22,7 +22,7 @@ numbers some of the same ground differently; the mapping is noted per row. | **T** — terminal screen | VT100/xterm grid, ANSI parser, TUI hosting, keyboard encoding | **Done** | `swift/Mouse/TerminalScreen.swift`, `TerminalWidth.swift`, `TerminalPrograms.swift`. Gated: screen corpus, pyte cross-check, wide-char/alt-screen/DECCKM/bracketed-paste harnesses (`verify/tty`, `altscreen`, `widetui`, `inkwide`) | | **A** — shell language | msh's POSIX-subset grammar: control flow, `$()`, functions, redirects | **Done** | `swift/Mouse/ShellLanguage.swift`. Gated against real `/bin/sh` on the 25-script corpus (`verify/shell`) | | **F** — package manager | Real npm registry, semver, integrity, lockfiles; `npm run`/`create`/`npx`; native-binary → wasm-build substitution | **Done** | `swift/Mouse/PackageManager.swift`. Gated: semver corpus vs pnpm, `verify/pkg`, `firstrun` (scaffold→install→dev end to end) | -| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. 147-harness suite (`verify/`), fixtures byte-identical to real node v22. Added since: `lightningcss` (the native CSS transformer, substituted with its authors' wasm build), `watchroot` (a watcher rooted at the project root, which is where the restart storm lived), `stackshape` (error.stack carrying its message, the way V8 writes it), `tailwind` (version 3 compiles here; version 4 cannot, and the wall is Apple's) | +| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. 148-harness suite (`verify/`), fixtures byte-identical to real node v22. Added since: `lightningcss` (the native CSS transformer, substituted with its authors' wasm build), `watchroot` (a watcher rooted at the project root, which is where the restart storm lived), `stackshape` (error.stack carrying its message, the way V8 writes it), `tailwind` (version 3 compiles here; version 4 cannot, and the wall is Apple's) | | **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). A real SvelteKit project runs `npm run dev` on the device: starts once, pre-bundles its 20 dependencies, and answers a curl from the Mac with HTTP 200 and the rendered 33 kB page. Remaining piece is the Preview surface (phase C) | | **B** — WebView JIT | Move JS/wasm execution into WKWebView for JIT speed | **Not started — optional** | Measured: everything runs interpreted; the JIT buys speed, not capability (system.md:2094). No longer a prerequisite for anything | | **C** — Preview container | In-app viewing surface for what dev servers serve; LAN hosting | **Not started** | The server half works (vite serves clients outside the app — gated in `verify/devserver`); no in-app viewer exists | diff --git a/verify/leaveproject/main.swift b/verify/leaveproject/main.swift new file mode 100644 index 0000000..f43076c --- /dev/null +++ b/verify/leaveproject/main.swift @@ -0,0 +1,77 @@ +import Foundation +setvbuf(stdout, nil, _IONBF, 0) + +// Leaving a project must take its running program with it. +// +// Tapping the project's name in the Files container goes back to the picker, and the ring drops +// the terminal session it had memoized on that workspace. Dropping it without stopping it strands +// whatever was running: a dev server keeps its port, keeps serving, and nothing on screen can +// reach it any more — the "a running program could not be stopped at all" trap in a new costume, +// with killing the app as the only way out. +// +// Asking politely is not enough and that is the point of testing it here rather than trusting it. +// A ^C is a KEYSTROKE, and a program decides what to do with one; vite reads it and keeps serving. +// So `stopForProjectChange` uses the close-button path, and this drives the same TerminalSession +// the app owns to prove the port actually goes away. +@MainActor func run() async { + let base = FileManager.default.temporaryDirectory.appendingPathComponent("leaveproject-\(getpid())") + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let port = 5391 + let server = """ + const http = require('http'); + http.createServer((req, res) => res.end('still here')).listen(\(port), () => { + console.log('listening on \(port)'); + }); + """ + try? server.write(to: base.appendingPathComponent("server.js"), atomically: true, encoding: .utf8) + + func answers() async -> Bool { + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/")!) + request.timeoutInterval = 2 + guard let (data, _) = try? await URLSession.shared.data(for: request) else { return false } + return String(decoding: data, as: UTF8.self) == "still here" + } + func waitUntil(_ wanted: Bool, seconds: Double) async -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if await answers() == wanted { return true } + try? await Task.sleep(nanoseconds: 200_000_000) + } + return await answers() == wanted + } + + let session = TerminalSession(root: base) + _ = session.run("node server.js") + + var failures = 0 + func check(_ condition: Bool, _ label: String) { + if condition { print(" ok: \(label)") } else { failures += 1; print(" FAIL: \(label)") } + } + + // A gate that only proves the port is closed would pass against a server that never started. + check(await waitUntil(true, seconds: 60), "the server answered while the project was open") + // The full-screen launch path is the one the app uses for `npm run dev`, and the one whose + // program a project change has to stop. + check(session.program != nil, "the program owns the terminal — the path npm run dev takes") + + session.stopForProjectChange() + + check(await waitUntil(false, seconds: 20), "the port stops answering once the project is left") + let cleared = Date().addingTimeInterval(5) + while session.program != nil && Date() < cleared { + try? await Task.sleep(nanoseconds: 100_000_000) + } + check(session.program == nil, "the screen is reclaimed — no program left installed") + check(!session.isRunning, "the session is idle afterwards") + + if failures == 0 { + print("LEAVE PROJECT: 5 checks — a project change stops what the project was running, " + + "and its port goes with it — MATCH") + } else { + print("LEAVE PROJECT: \(failures) of 5 checks failed — MISMATCH") + exit(1) + } +} +await run() From 9ca871c5d92a5de9c2192b8c325079e9797f9f75 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 02:29:14 -0400 Subject: [PATCH 30/32] Reconciled at HEAD, because the engine moved after the last green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- STATUS.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/STATUS.md b/STATUS.md index f2c9a01..f958155 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,11 +1,12 @@ # STATUS.md — the verified state of Mouse -Last reconciled: 2026-08-12, at commit dbd5d2d (the `npm run dev` run, -d27ffdd..dbd5d2d), by running the whole suite rather than trusting the last -green one: **141 assertions passed, 0 failed, 0 build-broken**, plus the five +Last reconciled: 2026-08-12, at commit 755a1c1 (the `npm run dev` run, +d27ffdd..755a1c1), by running the whole suite rather than trusting the last +green one: **143 assertions passed, 0 failed, 0 build-broken**, plus the five harnesses `verify.sh` declares investigations and does not count — `breadth`, -`glob`, `reachable`, `shapes`, `vitestrun`. Before that, 2026-07-31 at 953dd98 -(end of the phase-G run, 27ed75b..953dd98, 184 commits). +`glob`, `reachable`, `shapes`, `vitestrun`. Re-run at HEAD rather than left at +the earlier green, because `TerminalSession` changed after it. Before that, +2026-07-31 at 953dd98 (end of the phase-G run, 27ed75b..953dd98, 184 commits). This file is the one place that says where every phase stands, with the evidence named. When a phase's state changes, this file moves with it — same rule as From 8d572783baadc7892b1df1d13c75491a950550d8 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 02:30:17 -0400 Subject: [PATCH 31/32] Write down the approach not taken, and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/npm-dev-brief.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md index c56faa8..6f898b8 100644 --- a/.claude/npm-dev-brief.md +++ b/.claude/npm-dev-brief.md @@ -68,8 +68,22 @@ Each verified on the simulator, each with its evidence in its commit message. - **An error the ENGINE throws still has no header when read through `.stack`.** JSC materialises `stack` during construction, on its own errors as much as - ours, and no hook sees that moment. Errors JavaScript constructs are covered; - console and util.inspect rebuild the header for the rest. + ours, so nothing catches the moment an engine error is born. Errors JavaScript + constructs are covered (`verify/stackshape`); console and util.inspect rebuild + the header for the rest. + + There IS one more approach, designed and deliberately not attempted, so nobody + has to rediscover it or start it blind. `stack` is writable, and the engine + already rewrites every ESM module — so `catch (e) {` could become + `catch (e) { __mouseFixStack(e);`, giving an engine-thrown error its header at + the moment code catches it. That is exactly where SvelteKit caught the + TypeError this loop chased. Why it was not done: it is a fifth rewrite in + `transpileESM`, and two of the four already there shipped bugs found only by a + real bundle failing (`svelte.dev` inside a string, shorthand across newlines). + It would cover only transpiled ESM, not the CJS vite bundles its own chokidar + into. And it needs its own gate for `catch` with no binding, a rethrown `e`, + and a `catch` inside a template or a comment. Worth doing on a fresh context + with room to gate it properly; not worth starting at the end of one. ## The target, concretely From 0e30f3022ad3c90ef463e5436ea3656dc7442e80 Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 12 Aug 2026 04:39:57 -0400 Subject: [PATCH 32/32] The Files container's docs gain the way out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- swift/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift/README.md b/swift/README.md index e760edf..4dad838 100644 --- a/swift/README.md +++ b/swift/README.md @@ -55,7 +55,7 @@ The model lives in `Mouse/CarouselDeck.swift`, the interaction layer in `Mouse/F - **Container content** (`ContainerContent`) — what's inside, reference-typed and separate from the instance. Kinds in `ContainerContent.syncedKinds` resolve to a single app-wide object, so every instance of that kind, across all rings, shares (and live-updates from) the same state. Other kinds get private content per instance. To sync more kinds, add them to that set. - **Onboarding ring** (`CarouselDeck.onboarding()`, preset kinds ≤ 0) — first launch opens a dedicated, isolated ring of lesson containers (blank fillers + one lesson at a time; empty reserve, so nothing else swipes). No arrows anywhere: each lesson's `idle` animation suggests its gesture, and the label flips to past tense live, mid-gesture, via the content's `done` flag. The chain: **Swipe?** (horizontal bounce; "Swiped." past the commit threshold; retires once swiped, handing its lane to) → **Drag?** (label rides the divider gap; gap-reach idle — both sides of the gap move the SAME direction via the deck's shared `dragPulse`, so the boundary itself visibly travels up and back; "Dragged." mid-drag, then morphs into) → **Spread?** (same gap label; gap-breathe idle — both sides retreat in OPPOSITE directions via the shared `spreadPulse`, widening the space as a bounce; the two-finger spread opens a new lane, which arrives blank and only takes on) → **Pinch?** (after "Spread." has left the stage; shrink pulse; flips to "Pinched." and removes itself when pinched closed) → **Edge Swipe?** (rotated label in the ring's right gutter — rendered inside the stack, so it gets dragged along with the ring as the user swipes it away; "Edge Swiped." mid-drag). The drag lesson's word rides its boundary pulse; the spread word holds still while its gap breathes. Lessons appear strictly one at a time — the edge lesson waits until no lesson container is in the lanes at all — and gap labels live outside the container, so idle animations never move them. Every lesson word uses the same type (IBM Plex Mono, 28pt, black when outside a container); the layout makes room for the words rather than shrinking them: dividers grow to `lessonDividerHeight` while a gap-label lesson is up (lanes re-fit automatically), and the side gutters widen during the edge lesson to fit the rotated label. The edge swipe creates the user's first real ring, and the onboarding ring retires behind them once the transition settles. All lesson states persist (`done` in `InstanceSnapshot`, `isOnboarding` in `RingSnapshot`); a lesson saved mid-morph restores as its successor. - **GitHub container** (`Mouse/GitHubAuth.swift`, catalog kind 1) — the first real container: sign in with GitHub via the OAuth Device Flow against a classic **OAuth App** with `repo` scope: the token acts as the user — every repo they can touch, no installation step, no expiry (a 401 signs out cleanly; there is no refresh dance). A GitHub App provider existed briefly and was removed — its install-and-approve model 403'd ordinary personal use; restore from history if an org ever needs narrow permissions. The container shows a code to enter at github.com/login/device (tap to copy, button to open), polls until authorized, then shows `@login`. Tokens (access + refresh) live in the Keychain; the login handle is cached for instant restore and the session re-validates in the background (refresh-once on 401, sign-out if that fails, offline keeps the cached session). Sign-in state is app-global — every instance of the GitHub container in any ring reflects the same session. -- **Workspace** (`Mouse/Workspace.swift`, `Mouse/WorkspaceViews.swift`) — a project: a working tree on local disk plus shared project state. **One workspace per repo, app-wide**: rings opening the same repo share the same object (tree, dirty set, sync state, graph cache stay in step), while each ring keeps its own *viewport* — open file and terminal session — so several rings on one repo are several editors over one project: swipe between rings to switch files. The **Files container** (kind 2) starts projects: "+ new local project" (an empty tree on this device, born with a local git repo — commit/branch/merge work at once, and `sync` publishes it to GitHub on first push), the existing local projects, and the signed-in user's repos, downloaded via GitHub's tarball API (native gunzip + tar extraction — ustar/GNU-longname/pax, shared infrastructure for the future package manager). The tree browses by vertical scroll (tap folders to expand, tap a file to open) and ends with "+ add file": name a file (subfolders in the name are created), it lands empty, marked modified, and opens in the viewer. The **Viewer container** (kind 3) shows the workspace's open file: mono, line numbers, wrapped lines (no horizontal panning per the gesture law), read-only for now. Ring ↔ workspace association and the open file persist; a missing tree falls back to the repo picker. Real containers (GitHub/Files/Viewer) render black, terminal-styled. libgit2 (real clones, status, commits) arrives with the Source Control phase. +- **Workspace** (`Mouse/Workspace.swift`, `Mouse/WorkspaceViews.swift`) — a project: a working tree on local disk plus shared project state. **One workspace per repo, app-wide**: rings opening the same repo share the same object (tree, dirty set, sync state, graph cache stay in step), while each ring keeps its own *viewport* — open file and terminal session — so several rings on one repo are several editors over one project: swipe between rings to switch files. The **Files container** (kind 2) starts projects: "+ new local project" (an empty tree on this device, born with a local git repo — commit/branch/merge work at once, and `sync` publishes it to GitHub on first push), the existing local projects, and the signed-in user's repos, downloaded via GitHub's tarball API (native gunzip + tar extraction — ustar/GNU-longname/pax, shared infrastructure for the future package manager). Its first line is the project's name, and tapping that goes back to the picker — leaving stops whatever the ring's terminal was running, outright rather than with a ^C a dev server would read as a keystroke and ignore, so no server keeps a port after its project is gone (`verify/leaveproject`). The tree browses by vertical scroll (tap folders to expand, tap a file to open) and ends with "+ add file": name a file (subfolders in the name are created), it lands empty, marked modified, and opens in the viewer. The **Viewer container** (kind 3) shows the workspace's open file: mono, line numbers, wrapped lines (no horizontal panning per the gesture law), read-only for now. Ring ↔ workspace association and the open file persist; a missing tree falls back to the repo picker. Real containers (GitHub/Files/Viewer) render black, terminal-styled. libgit2 (real clones, status, commits) arrives with the Source Control phase. - **Git module toolbar** (`GitModuleToolbar` in `Mouse/GitGraphView.swift`, plus `Mouse/GitHubPush.swift`) — a fixed row of five labeled controls in the Graph container's header, beneath the `owner/repo history` title: **`commit · sync · branch · merge · refresh`**. They pre-exist and **dim** (white at 0.28) until each one's prerequisite is met, then brighten — a tap on a dimmed control isn't dead, it says why in the ring's terminal (`commit: nothing to commit, working tree clean`). **commit** (native `GitCore.commitAll`, enabled when the worktree differs from HEAD) prompts for a message and lands the whole worktree as one commit. **sync** pushes: for a repo with a local `.git` it publishes local commits via `GitRemote` (creating the GitHub repo on first push). Sync never pulls — the app must not rewrite a user's files on its own; bringing down remote commits is the explicit `git pull` in the terminal, which fetches incrementally (`have` negotiation), merges with the native engine, and refuses to run over uncommitted edits. For a downloaded (tarball) repo sync commits the modified files via the Git Data API when there are edits, else re-downloads the latest tree. It's the one control with in-flight state — a spinner while running, a red tint on failure — and its failure reason lands in the terminal. **branch** (enabled once the branch has a commit) switches between local branches or creates a new one. **merge** (enabled at two or more branches) three-way-merges a chosen branch into the current one via the native merge engine — fast-forward when possible, a merge commit for clean divergent edits, or conflict markers written to the worktree (the files are marked modified; resolve and commit). **refresh** (always usable — its only prerequisite is a workspace) re-derives the toolbar's enablement, re-checks the remote head, and rebuilds the graph, bypassing the dedupe stamp and the upstream throttle that automatic refreshes lean on. Failures and outcomes narrate to the terminal, the app's one honest error surface; the dirty set persists across launches. - **Graph container** (`Mouse/GitGraphView.swift`, kind 4) — the git module: the workspace's commit graph plus the git toolbar (above). The graph is colored branch rails with lane assignment computed from parent topology (newest-first; a commit lands on the rail expecting it, other expecting rails curve in and close, merge parents fan out or connect to existing rails), filled dots for commits, rings for merges, branch tips as capsule labels. History is LOCAL (from `GitCore`, full branch topology, no network) whenever a `.git` exists, else the GitHub API for downloaded repos. Vertical scroll only. - **Terminal container** (`Mouse/Terminal.swift` + `Mouse/Shell.swift`, kind 5) — switchable engines behind one prompt (the top-left chip cycles them). **`msh`** is a from-scratch POSIX-flavored shell (iOS has no fork/exec — the a-Shell model) with a real language (`Mouse/ShellLanguage.swift`: lexer, AST, parser; the evaluator lives in `Shell.swift`): control flow (`if`/`elif`/`else`, `for`, `while`/`until`, `case` with `|` patterns, functions with `$1…$# $@`, `local`, `return`), command substitution (`$(…)`, backticks, nesting), arithmetic (`$((…))` with variables and comparisons), parameter operators (`${X:-def} ${X:=} ${X:+} ${#X} ${X#pat} ${X##} ${X%} ${X%%}`), field splitting and globbing of expansion results, quoting (`'…'`, `"…$VARS"`, `\`), variables (`$NAME`, `$?`, `$$`, `export`/`env`/`unset`, `NAME=value` prefixes scoped to their command), tilde and globs (`~ * ? [abc]`, matched against the workspace), pipelines (`|`, compounds allowed as elements — `… | while read x; do …; done` works), redirection (`> >> <`, including on compounds: `done < file`, `fi > out`), sequencing (`; && || !`), `test`/`[`, `read`, `set -e -x -o pipefail` and `set -- args`, `shift`, `break`/`continue`/`exit`, `eval`, `source`/`.`, script execution (`sh file args`, `sh -c 'cmd'`, `./script.sh` via shebang, `curl url | sh` — the install-script shape), comments and line continuations, and history (`history`, `!!`, `!N`). The language is verified against **real `/bin/sh`**: a 25-script corpus (control flow, functions, expansions, `set -e`, `while read`, an install-shaped script) runs through both shells with stdout and exit status compared (method in AGENTS.md). **`npm` / `pnpm` / `npx` are real** (`Mouse/PackageManager.swift`, phase F of system.md): `npm install [pkg[@range]]` resolves against the npm registry (full semver ranges), verifies tarball integrity (sha512), unpacks natively (`TarGz`), lays out a Node-compatible hoisted `node_modules`, records executables in a manifest, and updates `package.json`; `npm install` with no args installs from `package.json`, `npm ls` lists the tree, `npx ` installs and resolves the package's bin, and **`npm run