@ultimat3/cli 19.2.0 → 19.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CLAUDE.md +135 -9
  2. package/README.md +1 -1
  3. package/package.json +29 -29
  4. package/src/app-agents-md.ts +14 -3
  5. package/src/app-boundaries.ts +11 -2
  6. package/src/app-load.ts +96 -25
  7. package/src/budgets.ts +17 -6
  8. package/src/cmd-dev-fixture.ts +25 -0
  9. package/src/cmd-dev.ts +48 -46
  10. package/src/cmd-doctor.ts +61 -23
  11. package/src/cmd-generate.ts +25 -3
  12. package/src/cmd-i18n.ts +10 -3
  13. package/src/cmd-jobs.ts +56 -10
  14. package/src/cmd-test.ts +15 -10
  15. package/src/db-seed.ts +2 -1
  16. package/src/dev-queue.ts +16 -2
  17. package/src/dev-reload.ts +46 -0
  18. package/src/dev-render.ts +35 -9
  19. package/src/dev-runtime.ts +4 -1
  20. package/src/dev-sync.ts +11 -3
  21. package/src/dev-watch-tree.ts +226 -0
  22. package/src/dev-watch.ts +59 -37
  23. package/src/doctor-offline.ts +122 -0
  24. package/src/error-catalog.ts +4 -5
  25. package/src/fix-command.ts +40 -1
  26. package/src/fix-path.ts +10 -11
  27. package/src/flag-number.ts +15 -0
  28. package/src/generate-files.ts +24 -2
  29. package/src/generate-kinds.ts +54 -4
  30. package/src/generate-write.ts +25 -2
  31. package/src/gitignore.ts +145 -0
  32. package/src/hold.ts +50 -17
  33. package/src/index.ts +1 -1
  34. package/src/island-bundle.ts +2 -1
  35. package/src/island-states-load.ts +2 -1
  36. package/src/jobs-driver.ts +4 -1
  37. package/src/mcp-host.ts +18 -9
  38. package/src/parse.ts +17 -0
  39. package/src/path-segments.ts +14 -0
  40. package/src/prerender.ts +46 -20
  41. package/src/retry-memo.ts +37 -0
  42. package/src/scaffold-fixture.ts +17 -0
  43. package/src/serve.ts +40 -5
  44. package/src/source-files.ts +3 -1
  45. package/src/sw-artifacts.ts +71 -7
  46. package/src/templates/action.ts +47 -16
  47. package/src/templates/admin-page.ts +49 -1
  48. package/src/templates/island.ts +4 -2
  49. package/src/templates/scaffold-container.ts +12 -0
  50. package/src/templates/scaffold-docs.ts +7 -0
  51. package/src/templates/scaffold-entries.ts +4 -2
  52. package/src/templates/scaffold-repo.ts +7 -2
  53. package/src/templates/slice-foundation.ts +36 -0
  54. package/src/test-passes.ts +79 -0
  55. package/src/test-shards.ts +110 -36
  56. package/src/verify-checks.ts +11 -8
  57. package/src/verify-floor.ts +59 -3
  58. package/src/verify-step.ts +4 -4
  59. package/src/verify-tests.ts +14 -2
package/CLAUDE.md CHANGED
@@ -8,12 +8,13 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
8
8
  | stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
9
9
  | stderr | `write-line.ts`'s `writeErrorLine` — the same loop on fd 2, for a line that is not the command's answer. A `CommandResult` declaring `stream: 'stderr'` is routed there by `dispatch.ts`'s `sinkFor`, and `x mcp serve --transport stdio` is the one case: its fd 1 carries JSON-RPC frames, so the `✓ mcp stdio serving 13 tools` line rendered after the loop was a malformed frame. Neither renderer carries `stream`, exactly like `hold` |
10
10
  | Boot logs under `--json` | `dispatch.ts` calls core's `setLogStream('stderr')` when `args.json` is set, once, for all thirty commands. `x db migrate --json` printed the boot logger's `ultimate migrate applied` and then the command's own object, so `json.load` raised on the second document. A server's stdout stays its log stream; this is the CLI process only |
11
- | Test execution | `test-shards.ts`'s `testArgs` — ONE `bun test --parallel=N`, never N processes this repo packs itself. It did pack them, largest-first greedy over file SIZE, and the packer was deleted for buying **nothing**: four interleaved runs each on the 1296-file unit corpus gave 58.2/60.0/65.0/66.5s hand-packed against 54.5/57.8/61.7/64.5s under `--parallel=8`, within noise, because both are work-bound — 436.7s of file time is a 54.6s floor on 8 workers and the slowest single file is 20.5s. A greedy pack of 1296 small items lands near-optimal by accident. `--timings` is refused on the same evidence (#342). `--parallel` implies `--isolate`, so the per-file module registry is unchanged, and the per-worker database is too: `@ultimat3/testing`'s `workerId` already read `BUN_TEST_WORKER_ID`, which Bun sets 1..N. `ULTIMATE_TEST_WORKER` is set only for a single-shard `x test --worker I` rerun, which is one process |
11
+ | Test execution | `test-shards.ts`'s `testArgs` — ONE `bun test --parallel=N` per PASS (`test-passes.ts`; a selection mixing `live`/`e2e` files with the rest is two passes, the serial one at `--parallel=1`), never N processes this repo packs itself. It did pack them, largest-first greedy over file SIZE, and the packer was deleted for buying **nothing**: four interleaved runs each on the 1296-file unit corpus gave 58.2/60.0/65.0/66.5s hand-packed against 54.5/57.8/61.7/64.5s under `--parallel=8`, within noise, because both are work-bound — 436.7s of file time is a 54.6s floor on 8 workers and the slowest single file is 20.5s. A greedy pack of 1296 small items lands near-optimal by accident. `--timings` is refused on the same evidence (#342). `--parallel` implies `--isolate`, so the per-file module registry is unchanged, and the per-worker database is too: `@ultimat3/testing`'s `workerId` already read `BUN_TEST_WORKER_ID`, which Bun sets 1..N. `ULTIMATE_TEST_WORKER` is set only for a single-shard `x test --worker I` rerun, which is one process |
12
12
  | Numeric flags | `flag-number.ts` — one reader for `--port` / `--workers` / `--shard`. A bare `Number.parseInt` accepts `4abc` and answers `NaN`, which turned three checks into ones that cannot fail |
13
13
  | Shell quoting | `shell-quote.ts`'s `quoteArg` — every value the CLI pastes into a `fix:` or a reproduce line, `exec.ts`'s missing-program refusal and `test-shards.ts`'s reproduce command both. A name holding a space or a `;` interpolated bare is an instruction that runs something else |
14
14
  | Missing positionals | `MissingPositionalError`, never `BadFlagError` (names a flag that does not exist) and never `UnknownCommandError` (says a known command form is not one). Its `example` is a REAL invocation — `x g route <name>` in a shell is a redirect |
15
15
  | Bare subcommands | `CommandSpec.defaultSubcommand`, **declared**. The parser answered `subcommands[0]` until 1.2.0, so `x db` ran `gen` — the migration GENERATOR — because it sorted first, and `x mcp` started a server. A command with no defensible default declares none and `MissingSubcommandError` refuses the bare form; `parse.test.ts` pins the set at exactly `db` and `mcp`. Its fix is `x help <command>`. Both forms answer now: `--help` is read off the flag loop and `readSubcommand` is SKIPPED when it is set, so `x db --help`, `x mcp --help` and `x pr --help` print usage instead of exiting 1 with this same refusal — which is what they did on every command taking a subcommand until 2026-08 (`parse.test.ts` pins it across the shipped registry) |
16
16
  | Closed flag values | a flag whose values are a closed set is READ through a function that refuses the rest — `cmd-build.ts`'s `readTarget`, `cmd-deploy.ts`'s `readMethod`, `cmd-routes.ts`'s `readSurfaceFilter`, `cmd-mcp.ts`'s `isTransport`. `=== 'helm' ? 'helm' : 'compose'` made `x deploy --method helmm` a COMPOSE deploy reporting `method: "compose"`, and `--surface App` reported `0 routes` and exit 0 — a typo and an empty table rendering identically. The set is the framework's own where one exists (`SURFACES` from `@ultimat3/render`), never a list restated here |
17
+ | Passthrough | `CommandSpec.passthrough`, **declared**. `ParsedArgs.passthrough` says "handed to the underlying tool verbatim" and had no reader anywhere, so `x test unit -- --coverage --bail` parsed both flags and dropped both — a run that measured nothing reporting what a coverage run reports. `x test` forwards them to `bun test` (before the file list) and prints them back in its reproduce line; every other command refuses a non-empty `--` tail with `X_CLI_BAD_FLAG` rather than dropping it. `help`/`version` short-circuit ahead of the command and are exempt |
17
18
  | App root | `CommandSpec.requiresApp`, **enforced by `dispatch.ts`** before `target.run` — the field's doc said so for 17 commands and nothing read it, so the promise was kept only by each command remembering to call `requireAppRoot` itself. Those 17 calls stay (they hand the command its root, and name subcommands the dispatcher cannot see), but the DECLARATION is what decides, ahead of any check a command makes about its own arguments; `--help` is exempt, because `target` is the help command by then |
18
19
  | Result helpers | `command.ts`'s `ok()` / `failed()` write `ok` **after** the `extra` spread: the function's name is the verdict and nothing a caller passes can overturn it. Spread last, `failed('verify', '1 of 20 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
19
20
  | I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
@@ -81,7 +82,7 @@ source only: a test file's import is not judged, because `packages/*` here decla
81
82
  read is its own finding rather than a silent skip — a skipped workspace is a hiding place for the
82
83
  very edge the rule is looking for.
83
84
 
84
- `app-permissions.ts` is the `policy` step, and it is the twentieth. Two references in the whole
85
+ `app-permissions.ts` is the `policy` step. Two references in the whole
85
86
  framework are bare strings nothing checks — `RoleDef.grants` and `RouteGuard.permission` — while
86
87
  `can()` calls `assertPermission` and throws `X_PERMISSION_UNKNOWN` on the first request that
87
88
  reaches the route. So `x new` shipped an app that granted `dashboard:read`, required it on
@@ -108,9 +109,19 @@ halves are `undefined`/empty with no replica configured, and an EMBEDDED binding
108
109
  PGlite has no standby. Not `@ultimat3/http`'s pipeline, which would make the HTTP tier know what a
109
110
  database is; the boot is the only tier that may know about a request and a pool.
110
111
 
112
+ `flag-number.ts`'s `portPairAfter` is the one suggestion for a caller that binds a PAIR, and
113
+ `neighbouringPort` is for a caller that binds one port. `x dev` takes `PORT` **and** `PORT + 1`, so
114
+ the neighbour of a taken web port is the sync port — both `x doctor`'s `X_PORT_IN_USE` and
115
+ `dev-sync.ts`'s handed back the port the finding had just named as taken, each under a test that
116
+ said it ended the failure. It answers downward at the top of the range for the reason
117
+ `neighbouringPort` does, and one further: the suggestion's own neighbour has to exist, or
118
+ `syncPortFor` refuses it with `X_PORT_INVALID`.
119
+
111
120
  `port-probe.ts` is the one `portFree`, because two commands ask it and must not disagree:
112
- `x doctor` reports it as a finding for BOTH ports `x dev` binds — the web port and the `PORT + 1`
113
- sync port, each labelled with the role that wants it — and `startSync` asks it after a failed
121
+ `x doctor` reports it as a finding for BOTH ports `x dev` binds — the web port and the sync port,
122
+ which it derives with `syncPortFor` (the boot's own rule: a `--port` at the top of the range has no
123
+ sync port at all, so the probe reports `X_PORT_INVALID` instead of probing the port BELOW it, which
124
+ is what `neighbouringPort` answered there) — each labelled with the role that wants it — and `startSync` asks it after a failed
114
125
  `listenSyncNode` so a taken neighbour is `X_PORT_IN_USE` rather than `X_CLI_UNEXPECTED` over
115
126
  `Bun.serve`'s own English rendered into a `cause:`. It is ASKED, never read off the caught value,
116
127
  which is what `scripts/catch-render.ts` refuses; anything else the listener failed on is re-thrown
@@ -119,6 +130,36 @@ untouched. `x doctor` also probes `DATABASE_URL` with a real `select 1` through
119
130
  credentials, which is the case an operator most needs told about — and reports `X_DB_UNAVAILABLE`
120
131
  with that package's own two-branch fix. An EMBEDDED binding is not probed: that lock is `x dev`'s.
121
132
 
133
+ `doctor-offline.ts` is `x doctor`'s offline-fallback check, and it is the shape of a diagnostic
134
+ that could not be closed. The probe was the literal `apps/web/app/offline.tsx` — a filename
135
+ `assertRouteFilename` REFUSES (`X_ROUTE_FILE_INVALID`: the directory is the URL, so a page is
136
+ `page.tsx`) — while `x new` scaffolds `apps/web/site/offline/page.tsx` and the finding's own `fix:`
137
+ writes `apps/web/app/offline/page.tsx`. Three different paths, so every app the framework has ever
138
+ produced reported `X_PWA_NO_OFFLINE_FALLBACK` from its first `x doctor`, running the fix changed
139
+ nothing, and the app that HAD the route was told it did not. It now reads the DECLARED
140
+ `pwa.offline.fallback` through `loadPwaArtifacts` and matches it against `describeRoutes()` —
141
+ **`site/` only**, `As of 2026-09`. It accepted `app/` too, on the argument that both surfaces answer
142
+ the same URL; true, and not the question. `SURFACE_SPECS` allows `app/` exactly `stream | ssr`, only
143
+ a `static` route is prerendered, and `serviceWorkerArtifacts` precaches a rendered DOCUMENT
144
+ (`documents.get(fallback)`) — so an `app/` fallback has nothing to precache and the offline
145
+ navigation reaches the network it exists to survive without. The check and its own `fix:` disagreed
146
+ about one code. Not closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
147
+ either, and `NavigableRoute` carries no render mode. An app whose modules will not import is not
148
+ judged at all, which is
149
+ `appEntities`' rule (`schema-drift.ts`) one registry over. The fix is `x g route <name>
150
+ --surface site` only where the fallback is one path segment the generator can really produce: a
151
+ nested path slugifies to a DIFFERENT url, so there it is the config edit instead — a `fix:` that
152
+ runs and leaves the finding where it was is the defect this file exists to end. **`site`, not
153
+ `app`**: the document that answers a lost network has to render with no network, no session and no
154
+ database, which `app/` (`ssr | stream`) cannot promise, and it is the line `@ultimat3/pwa`'s own
155
+ `X_PWA_NO_OFFLINE_FALLBACK` hands out for the same code — two fixes for one code are two answers.
156
+
157
+ `X_ENV_MISSING`'s fix is the file write, for the same reason. It was `x new --force`, which cannot
158
+ run where the reader is standing: `x new` takes a `<name>` positional (`X_CLI_BAD_FLAG` without
159
+ one) and with one it scaffolds a SECOND app beside the broken one. `cp .env.example
160
+ .env.development` — the committed projection of `envSchema`, which is what `x env example` writes
161
+ and `X_ENV_EXAMPLE_DRIFT` keeps honest.
162
+
122
163
  `i18n-index.ts` is the one writer of an app's `packages/i18n/src/index.ts`, shared by `x g` and
123
164
  `x i18n add|sync`. A catalog on disk and a SELECTABLE locale were two different sets: `x i18n add
124
165
  fr` wrote the file, exited 0, and left `x verify --only i18n` red with `X_CATALOG_UNREGISTERED`
@@ -406,7 +447,26 @@ cannot be missing from the precache manifest.
406
447
  |---|---|
407
448
  | `cmd-dev.ts` | mounts `/sw.js` and `/x-sw-register.js`; built ONCE at boot and deliberately not rebuilt on the watcher tick — a worker that changes under a page it already controls is the update path, and re-emitting one per keystroke exercises it on every save |
408
449
  | `serve.ts` | the same two routes in the container, from the same call |
409
- | `prerender.ts` | writes both as FILES into the export — a static host runs no route table, so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the artifact |
450
+ | `prerender.ts` | writes both as FILES into the export — a static host runs no route table, so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the artifact. In TWO halves, `As of 2026-09-06`: `serviceWorkerHead(pwa)` before the render loop, because every document has to name the script, and `serviceWorkerArtifacts` AFTER it, because a precache revision is the content hash of a document that does not exist yet |
451
+
452
+ **A precache revision is the DOCUMENT's content hash, and until 2026-09-06 it was the build id.**
453
+ `pwaRoutes` projected four of `PwaRoute`'s eight fields, so every route entry read
454
+ `{"url":"/","revision":"<buildId>","bytes":0}`: two deploys of a byte-identical site re-fetched
455
+ every precached page, which is the one thing `packages/pwa/src/precache.ts`' own header says must
456
+ never happen, and `DEFAULT_PRECACHE_WARN_BYTES` was a 5 MB budget over a total that could not count
457
+ one byte of HTML. The cause was ORDER — `prerender.ts` emitted the worker before the render loop —
458
+ so the emission moved after it and `ServiceWorkerInput.documents` carries `renderStatic`'s own
459
+ `hash` and byte count per rendered path. `x dev` and `serve.ts` pass none and keep the build id:
460
+ neither has rendered a page at boot, and inventing a hash for bytes that do not exist is a revision
461
+ that never changes when the page does. **The offline document is fed through its own pair**, because
462
+ `buildPrecacheManifest` adds it ITSELF as `reason: 'fallback'` ahead of every route and `add()`
463
+ keeps the first entry per url — so the route entry of the same url is shadowed and only
464
+ `offlineFallbackRevision` / `offlineFallbackBytes` (`ServiceWorkerConfig`, `@ultimat3/pwa`) can
465
+ decide it. That pair was declared and fed by nobody for one commit; `serviceWorkerArtifacts` reads
466
+ the fallback path out of the same `documents` map, so the one page an offline navigation depends on
467
+ is content-addressed like every other. A fallback this pass did not render — no route serves it,
468
+ which `x doctor` reports as `X_PWA_NO_OFFLINE_FALLBACK` — is absent from the map and keeps the
469
+ build id.
410
470
 
411
471
  **Registration is an EXTERNAL script, never inline**, and that is a CSP fact rather than a
412
472
  preference: `startWeb` computes a `script-src` sha256 per inline script, so an unhashed one is
@@ -996,7 +1056,11 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
996
1056
  | `statement-loop.ts` | one verdict → the finding, the panel fact, the overlay notice and the log line |
997
1057
  | `dev-policy.ts` | which actors to ask about, and which capability each policy gates |
998
1058
  | `cmd-dev.ts` | boot order, mounting `/_x`, installing the span exporter, the file watcher |
999
- | `dev-watch.ts` | which writes under the app root are a source change. Seven directories are never one — `.x`, `node_modules`, `.git`, `.personal`, `.claude`, `dist`, `coverage` — and each is matched as a path SEGMENT: the rule was `filename.includes('.x/') \|\| filename.includes('node_modules')`, so `git status`, an agent's scratch file and a coverage run each ran a full `appManifest()` + `buildIslands()` (measured in ai-maxxing, which keeps two whole copies of the app under `.claude/worktrees/`), while a directory named `my-node_modules-notes/` got no reload at all |
1059
+ | `dev-watch.ts` | which paths under the app root `x dev` may not watch: the app's own `.gitignore` (`gitignore.ts`) plus a floor of five directory names an ignore file cannot be relied on to carry — `.git`, `.x`, `node_modules`, `.personal`, `.claude` — every one of them dotted or an install, matched as a path SEGMENT at any depth |
1060
+ | `dev-watch-tree.ts` | the watch SET: one `watch(dir, { recursive: false })` per ADMITTED directory, a new directory picked up on `rename`, a removed one giving its descriptor back, and the 30ms trailing debounce |
1061
+ | `dev-reload.ts` | one rebuild at a time — a tick arriving mid-build coalesces into ONE trailing rebuild, for the newest file |
1062
+ | `gitignore.ts` | what git ignores, as data: parse, the ancestor chain up to the repository, and last-match-wins |
1063
+ | `path-segments.ts` | `hasPathSegment` — a directory of that exact name, never a substring of one |
1000
1064
  | `style-bundle.ts` / `style-routes.ts` | a surface's CSS as one content-hashed file under `/styles/`, served `immutable` — `island-bundle.ts` / `island-routes.ts`' shape one asset over. It was an inline `<style>` until 2026-09-06: 156,738 bytes, identical on every page, inside a `private, no-store` document. The URL is the hash alone, no surface in the name: a surface is not a property of the bytes, and an app whose only CSS is its global layer would otherwise write three identical files into its static export and three entries into a precache manifest that has a budget |
1001
1065
  | `mcp-host.ts` | the `DevCapabilities` half of `@ultimat3/mcp`'s `DevHost` — db, tests, logs, verify |
1002
1066
  | `mcp-db-target.ts` | which database the host is pointed at: whether it is a branch, and whether it is production |
@@ -1005,6 +1069,44 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
1005
1069
  | `mcp-test-output.ts` | reading `bun test`'s own summary back into a `TestRun` |
1006
1070
  | `cmd-mcp.ts` | `x mcp serve`: the two transports, and the local developer's caller |
1007
1071
 
1072
+ **The watch set is a REGISTRATION decision, not a filter, `As of 2026-09-06`.** `watch(root, {
1073
+ recursive: true })` takes one inotify descriptor per directory in the tree — measured on `x dev`
1074
+ against `examples/dummy`: **110 descriptors, 39 of them (35%) under `.x/` and `node_modules/`**, and
1075
+ on a monorepo root **1901, of which 1490 (78%) were `.git/` and `node_modules/`**, where one
1076
+ `git status` delivered 5 `.git/index` events into the JS callback. `isIgnoredPath` answered
1077
+ correctly every time and answered too late: the kernel queue entry, the callback and the slot out of
1078
+ `max_user_watches` (8192 on many distributions) were already spent. Bun 1.4.0's `fs.watch` has no
1079
+ ignore option, so `dev-watch-tree.ts` walks the root itself and registers one non-recursive watcher
1080
+ per admitted directory, pruning at descent.
1081
+
1082
+ **And the ignore set is the app's own `.gitignore`.** Seven hand-listed directory names were wrong
1083
+ in both directions. Nothing read the ignore file, so `touch tsconfig.tsbuildinfo` — the file every
1084
+ `bun run typecheck` rewrites, named by `x new`'s own scaffolded `.gitignore` — logged
1085
+ `reloaded tsconfig.tsbuildinfo in 113ms`, a full `appManifest()` plus `buildIslands()`; on the
1086
+ framework root 54 git-ignored directories were unfiltered. And `dist` and `coverage` were matched at
1087
+ ANY depth, so an app's own `/dist` or `/coverage` ROUTE never reloaded — silently, which the file's
1088
+ own header calls worse than a spurious rebuild. `gitignore.ts` reads it with git's own anchoring
1089
+ (unanchored at any depth, a leading `/` or an inner slash where it is written, `!` re-including,
1090
+ a trailing `/` directory-only) and walks ANCESTOR ignore files up to the directory holding `.git` —
1091
+ `examples/dummy` carries none of its own and every rule about it lives in the repository root's. The
1092
+ file is re-read, and the whole watch set re-walked, on any write naming `.gitignore`; nothing ever
1093
+ spawns `git check-ignore`. `x new`'s scaffold writes `/dist/` and `/coverage/` **root-anchored** for
1094
+ this reason, plus `packages/*/dist/`. It is `fix-path.ts`'s parser, lifted: two readers of one file
1095
+ are two answers to what an app committed.
1096
+
1097
+ **A watcher event carries no filename when the WATCHED directory itself moves.** Bun's recursive
1098
+ watcher delivers `undefined` — not `null` — on `mv myapp myapp2`, a re-clone or a volume remount,
1099
+ and `isIgnoredPath(undefined)` threw a `TypeError` inside an fs callback, outside any `try`, with no
1100
+ `uncaughtException` handler: `x dev` died with a stack trace. The listener is total over
1101
+ `string | Buffer | null | undefined` and logs `dev.watch.unnamed_event` once.
1102
+
1103
+ **The reload has an in-flight guard, and it is the state that needed it.** A 45ms drip — a slow
1104
+ `git checkout`, a formatter walking files, `x db gen` — measured **40 reloads for 40 files**, each
1105
+ launching `Promise.all([appManifest, buildIslands])` while the previous still ran and assigning
1106
+ `state.manifest` / `state.islands` in COMPLETION order, so a slower earlier tick could land on top
1107
+ of a newer one. `coalesceReloads` (`dev-reload.ts`) keeps the LAST tick that arrived during a
1108
+ rebuild and starts exactly one more.
1109
+
1008
1110
  `api-routes.ts` is the app's own API surface, composed **once** and mounted by both `cmd-dev.ts`
1009
1111
  and `serve.ts`: `listActions().map(toRoute)` from `@ultimat3/action` plus
1010
1112
  `listQueries().map(toQueryRoute)` from `@ultimat3/query`. Two lists is how `query.client()`
@@ -1022,6 +1124,16 @@ The roles live in `@ultimat3/core` (`ROLES`, `isRole`), never in a second list h
1022
1124
  driver, a dev-only authorizer or a dev-only queue is the bug this design exists to prevent — the
1023
1125
  only thing dev changes is which driver is behind an interface.
1024
1126
 
1127
+ ### `HOST` is the interface, read the way `PORT` is
1128
+
1129
+ `serve.ts`'s `hostnameFromEnv` — `HOST`, trimmed, empty is `0.0.0.0` — and `ServeOptions.hostname`
1130
+ overrides it as `port` overrides `PORT`; `containerBinding(env, hostname)` is the one `WebBinding`
1131
+ `serveApp` hands `startRoles`, so `web`, `sync` and the metrics endpoint bind the same interface.
1132
+ Before 2026-09-07 `CONTAINER_BINDING` was the only production binding and an app whose auth mode
1133
+ admits one implicit actor without a login — which must refuse a public interface — could not run in
1134
+ a container at all. A loopback bind in a container is unreachable through `-p`; the wiki row says
1135
+ where it IS reachable. Not `HOSTNAME`: Docker sets that to the container id.
1136
+
1025
1137
  ### `RuntimeOverrides` is the only way to hand the framework a driver
1026
1138
 
1027
1139
  `ServeOptions` was `{ root, env, role?, port?, metricsPort? }`, so the ONLY way an app could
@@ -1154,9 +1266,23 @@ Delete `graphHash` the day `Bun.build` is deterministic.
1154
1266
 
1155
1267
  **`x dev`, the container and the static export all mount the same table.** `serve.ts` builds the
1156
1268
  islands at boot for the same reason it mounts `apiRoutes()`: a seam that works in dev and not in the
1157
- image is the same failure one release later. `x dev` rebuilds them on the watcher tick, and that is
1158
- the one reload that actually takes effect — an island is the single module this process never
1159
- imports, so there is no Bun module cache to invalidate.
1269
+ image is the same failure one release later. `x dev` rebuilds them on the watcher tick — an island
1270
+ is the single module this process never imports, so there is no Bun module cache to invalidate —
1271
+ and the SAME tick re-imports the route module beside them when its source changed
1272
+ (`app-load.ts`'s `reloadRoute`: `<path>?x-reload=<hash>` is the one cache key Bun honours,
1273
+ `registerRoute` replaces the entry for the same file, and `dev-render.ts` reads the entry back from
1274
+ the table on every request rather than closing over the one it was built from). Until 2026-09-07
1275
+ the island was the only reload that took effect, so a save served a new island under an old page —
1276
+ the old props, the placeholder the new island renders when they are missing. A route module and
1277
+ nothing else: an action, a query or an entity is held by every module that imported it, and no
1278
+ re-import can rebind those. `@ultimat3/render`'s loader admits the query
1279
+ (`/\.tsx(?:\?[^/]*)?$/`) and strips it before reading the file — anchored on `.tsx$`, the
1280
+ re-import fell through to Bun's own JSX loader and every reloaded page died on `__xh`.
1281
+
1282
+ **The dev fixture is its own repository** (`.git/HEAD` in `DEV_FIXTURE_FILES`). The framework's root
1283
+ `.gitignore` lists `packages/cli/.dev-fixture/`, `devIgnore` honours every ancestor up to a `.git`,
1284
+ and so the watcher admitted the fixture root and nothing under it: every run booted the reload path
1285
+ and none exercised it. The marker is what lets `cmd-dev.test.ts` save a page and await the tick.
1160
1286
 
1161
1287
  **`app-load.ts` skips `*.island.tsx` deliberately.** It registers no primitive, and importing it
1162
1288
  would put the one module guaranteed to be outside the server's graph inside this process's, where a
package/README.md CHANGED
@@ -123,7 +123,7 @@ is held to the same error contract shipped source is (`X_GUARD_INVALID`, `X_GUAR
123
123
  | `app-openapi.ts` | `openapi.json`, projected by `@ultimat3/action` |
124
124
  | `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
125
125
  | `app-agents-md.ts` | `AGENTS.md` exists and stays short, over `@ultimat3/manifest`'s check |
126
- | `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it |
126
+ | `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it. `ROLE`, `PORT` and `HOST` are read from `env`; `role`, `port` and `hostname` on `ServeOptions` override each |
127
127
  | `prerender.ts` | `x build --target static`: which `site/` routes qualify, and where the bytes land |
128
128
  | `metrics-endpoint.ts` | the `METRICS_PATH` scrape listener every role opens, on `METRICS_PORT` |
129
129
  | `otlp-export.ts` | the exporters `OTEL_EXPORTER_OTLP_ENDPOINT` switches on, and their drain hooks |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "19.2.0",
3
+ "version": "19.3.2",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,34 +37,34 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "19.2.0",
41
- "@ultimat3/admin": "19.2.0",
42
- "@ultimat3/ai": "19.2.0",
43
- "@ultimat3/auth": "19.2.0",
44
- "@ultimat3/cache": "19.2.0",
45
- "@ultimat3/core": "19.2.0",
46
- "@ultimat3/db": "19.2.0",
47
- "@ultimat3/entity": "19.2.0",
48
- "@ultimat3/flags": "19.2.0",
49
- "@ultimat3/http": "19.2.0",
50
- "@ultimat3/i18n": "19.2.0",
51
- "@ultimat3/jobs": "19.2.0",
52
- "@ultimat3/mail": "19.2.0",
53
- "@ultimat3/manifest": "19.2.0",
54
- "@ultimat3/mcp": "19.2.0",
55
- "@ultimat3/money": "19.2.0",
56
- "@ultimat3/notify": "19.2.0",
57
- "@ultimat3/policy": "19.2.0",
58
- "@ultimat3/pwa": "19.2.0",
59
- "@ultimat3/query": "19.2.0",
60
- "@ultimat3/realtime": "19.2.0",
61
- "@ultimat3/render": "19.2.0",
62
- "@ultimat3/schema": "19.2.0",
63
- "@ultimat3/scraping": "19.2.0",
64
- "@ultimat3/seo": "19.2.0",
65
- "@ultimat3/storage": "19.2.0",
66
- "@ultimat3/testing": "19.2.0",
67
- "@ultimat3/time": "19.2.0",
40
+ "@ultimat3/action": "19.3.2",
41
+ "@ultimat3/admin": "19.3.2",
42
+ "@ultimat3/ai": "19.3.2",
43
+ "@ultimat3/auth": "19.3.2",
44
+ "@ultimat3/cache": "19.3.2",
45
+ "@ultimat3/core": "19.3.2",
46
+ "@ultimat3/db": "19.3.2",
47
+ "@ultimat3/entity": "19.3.2",
48
+ "@ultimat3/flags": "19.3.2",
49
+ "@ultimat3/http": "19.3.2",
50
+ "@ultimat3/i18n": "19.3.2",
51
+ "@ultimat3/jobs": "19.3.2",
52
+ "@ultimat3/mail": "19.3.2",
53
+ "@ultimat3/manifest": "19.3.2",
54
+ "@ultimat3/mcp": "19.3.2",
55
+ "@ultimat3/money": "19.3.2",
56
+ "@ultimat3/notify": "19.3.2",
57
+ "@ultimat3/policy": "19.3.2",
58
+ "@ultimat3/pwa": "19.3.2",
59
+ "@ultimat3/query": "19.3.2",
60
+ "@ultimat3/realtime": "19.3.2",
61
+ "@ultimat3/render": "19.3.2",
62
+ "@ultimat3/schema": "19.3.2",
63
+ "@ultimat3/scraping": "19.3.2",
64
+ "@ultimat3/seo": "19.3.2",
65
+ "@ultimat3/storage": "19.3.2",
66
+ "@ultimat3/testing": "19.3.2",
67
+ "@ultimat3/time": "19.3.2",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -15,11 +15,22 @@ export interface AgentsMdOutcome {
15
15
  readonly warnings: readonly string[];
16
16
  }
17
17
 
18
- /** `assertAgentsMd` throws `X_AGENTS_MD_*`; a gate step reports, so the error becomes a finding. */
19
- export async function checkAgentsMd(root: string): Promise<AgentsMdOutcome> {
18
+ /**
19
+ * `assertAgentsMd` throws `X_AGENTS_MD_*`; a gate step reports, so the error becomes a finding.
20
+ *
21
+ * `maxBytes` is this repository's own budget, out of `x.verify.json`, and `undefined` means the
22
+ * 12kB default. `@ultimat3/manifest` has taken the option since it was written and this function
23
+ * never passed one, so the default was the only budget any app could have: a repository whose
24
+ * conventions genuinely need more room had to delete a rule to make space, which is the opposite
25
+ * of what a context-file budget is for.
26
+ */
27
+ export async function checkAgentsMd(root: string, maxBytes?: number): Promise<AgentsMdOutcome> {
20
28
  const path = join(root, AGENTS_MD_FILENAME);
21
29
  try {
22
- const { warnings } = await assertAgentsMd({ path });
30
+ const { warnings } = await assertAgentsMd({
31
+ path,
32
+ ...(maxBytes === undefined ? {} : { maxBytes }),
33
+ });
23
34
  return { findings: [], warnings };
24
35
  } catch (error) {
25
36
  return { findings: [{ ...findingFrom(error), at: AGENTS_MD_FILENAME }], warnings: [] };
@@ -18,6 +18,7 @@ import { ERROR_DOCS_URL } from '@ultimat3/core';
18
18
  import type { BoundaryRule, ImportGraph } from '@ultimat3/render';
19
19
  import { checkSurfaceBoundary, importGraph, SURFACES } from '@ultimat3/render';
20
20
  import type { Finding } from './output';
21
+ import { hasPathSegment } from './path-segments';
21
22
  import { quoteArg } from './shell-quote';
22
23
 
23
24
  export const BOUNDARY_CODES = [
@@ -56,8 +57,16 @@ const isDbSpecifier = (specifier: string): boolean =>
56
57
  specifier.endsWith('/db') ||
57
58
  /^@[^/]+\/db$/.test(specifier) ||
58
59
  specifier === 'drizzle-orm';
60
+ /**
61
+ * `node:http` and `node:https` are HTTP too, and the anchor could not reach them: `http` had to
62
+ * follow a `/` or start the string, and a `node:` specifier has a colon there. So the one spelling
63
+ * that needs no dependency at all — a service reaching straight for the runtime's own client — was
64
+ * the one spelling this rule could not see. Matched on the boundary character rather than by
65
+ * listing every prefix, and still anchored at both ends: `node:http2-fake` and
66
+ * `@ultimat3/https-client` are names, not HTTP.
67
+ */
59
68
  const isHttpSpecifier = (specifier: string): boolean =>
60
- specifier === '@ultimat3/http' || /(^|\/)http($|\/)/.test(specifier);
69
+ specifier === '@ultimat3/http' || /(^|[/:])https?($|\/)/.test(specifier);
61
70
 
62
71
  /** The transpiler rejects a shebang, and an app's `bin/` entry points legitimately have one. */
63
72
  export const stripShebang = (source: string): string =>
@@ -236,7 +245,7 @@ export async function readAppSources(root: string): Promise<readonly SourceFile[
236
245
  for (const pattern of APP_GLOBS) {
237
246
  const glob = new Bun.Glob(pattern);
238
247
  for await (const path of glob.scan({ cwd: root, absolute: false })) {
239
- if (path.includes('node_modules') || path.includes('.test.')) continue;
248
+ if (hasPathSegment(path, 'node_modules') || path.includes('.test.')) continue;
240
249
  const posix = path.split('\\').join('/');
241
250
  files.push({ path: posix, source: await Bun.file(joinPath(root, posix)).text() });
242
251
  }
package/src/app-load.ts CHANGED
@@ -10,6 +10,7 @@ import { registerActions } from '@ultimat3/action';
10
10
  import { localeConfig } from '@ultimat3/i18n';
11
11
  import type { ErrorCodeFact } from '@ultimat3/manifest';
12
12
  import { registerQueries } from '@ultimat3/query';
13
+ import type { RouteConfig } from '@ultimat3/render';
13
14
  import { isRouteConfig, pageComponentOf, registerRoute } from '@ultimat3/render';
14
15
  // For the SIDE EFFECT, and it is this module's to hold: importing `@ultimat3/render/server`
15
16
  // installs the `.tsx`/`.scss` Bun plugin, a plugin only transforms modules loaded AFTER it, and
@@ -21,6 +22,7 @@ import '@ultimat3/render/server';
21
22
  import { collectDeclaredCodes } from './error-contract';
22
23
  import type { Finding } from './output';
23
24
  import { findingFrom } from './output';
25
+ import { hasPathSegment } from './path-segments';
24
26
 
25
27
  /** Every place an app keeps code the framework has to see. */
26
28
  const APP_GLOBS = [
@@ -73,20 +75,32 @@ export interface LoadedApp {
73
75
  readonly findings: readonly Finding[];
74
76
  }
75
77
 
76
- // A module is imported and registered exactly once per PROCESS: `import()` caches, and a registry
77
- // rejects a second registration of a name. So a rescan refreshes only the facts DERIVED from the
78
- // registries — the manifest and its build id — and never the primitives themselves: an edited route
79
- // config, action or query needs a restart. Clearing the registries would not change that. Bun
80
- // exposes no way to invalidate a cached module, so the re-import hands back the same stale exports,
81
- // and a cache-busting query string leaks a fresh module instance on every save.
78
+ // A module is imported and registered once per PROCESS: `import()` caches, and a registry rejects
79
+ // a second registration of a name. So a rescan refreshes the facts DERIVED from the registries —
80
+ // the manifest and its build id — and, for exactly one kind of module, the primitive itself. A
81
+ // ROUTE module whose source changed is imported again under `?x-reload=<hash>`, the one cache key
82
+ // Bun honours, and `registerRoute` replaces the entry for the same file; its own imports resolve
83
+ // to the modules already cached, which is what makes it safe. An action, a query or an entity
84
+ // stays registered once: its exports are held by every module that imported it, a second instance
85
+ // would be a duplicate name in its registry, and no re-import can rebind the importers — those
86
+ // edits need a restart. Until 2026-09-07 the route module took the same rule, so a save re-bundled
87
+ // the island (`buildIslands` reads the disk) and kept the FIRST page component — a new island
88
+ // rendering under an old page's props, which is the mixed generation `x dev` served.
82
89
  const registered = new Set<string>();
83
90
  // A registration failure is sticky: the file is never retried, so the finding is replayed.
84
91
  const failures = new Map<string, Finding>();
92
+ // Route modules only: the hash of the source each one registered from, which is what a rescan
93
+ // compares the disk against. A save that leaves the bytes alone re-imports nothing.
94
+ const routeSources = new Map<string, bigint>();
95
+
96
+ /** The query a re-imported route module carries. `module-loader.ts`'s filter admits it. */
97
+ const RELOAD_QUERY = 'x-reload';
85
98
 
86
99
  /** Test seam, and what `x dev` would call if it ever restarted the registries in-process. */
87
100
  export function resetAppLoad(): void {
88
101
  registered.clear();
89
102
  failures.clear();
103
+ routeSources.clear();
90
104
  }
91
105
 
92
106
  export async function loadApp(root: string): Promise<LoadedApp> {
@@ -95,11 +109,21 @@ export async function loadApp(root: string): Promise<LoadedApp> {
95
109
 
96
110
  for (const pattern of APP_GLOBS) {
97
111
  for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
98
- if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
112
+ // A SEGMENT, never a substring: an app checked out under
113
+ // `~/dev/node_modules-experiments/myapp` answered `includes('node_modules')` for every
114
+ // file it holds, so this loop imported none of them and the app registered nothing.
115
+ if (hasPathSegment(absolute, 'node_modules') || absolute.includes('.test.')) continue;
99
116
  const file = relative(root, absolute).split(sep).join('/');
100
117
  if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
101
118
  continue;
102
119
  }
120
+ // The source is read BEFORE the import, and only on the file's first pass — a rescan of a
121
+ // registered module reads nothing here. A route entry is bound to the bytes the module was
122
+ // evaluated from, and a read AFTER the import cannot know which bytes those were: a save
123
+ // landing between the two bound V1's component to V2's hash, so the next scan saw nothing to
124
+ // do and served V1 until the save after. Read first, the worst case is one re-import the
125
+ // next tick, of a file that did change.
126
+ const snapshot = registered.has(absolute) ? undefined : await Bun.file(absolute).text();
103
127
  let module: Record<string, unknown>;
104
128
  try {
105
129
  module = (await import(absolute)) as Record<string, unknown>;
@@ -108,7 +132,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
108
132
  continue;
109
133
  }
110
134
  files.push(file);
111
- const finding = await register(absolute, file, module);
135
+ const finding = await register(absolute, file, module, snapshot);
112
136
  if (finding !== undefined) findings.push(finding);
113
137
  }
114
138
  }
@@ -124,35 +148,29 @@ export async function loadApp(root: string): Promise<LoadedApp> {
124
148
  };
125
149
  }
126
150
 
127
- /** Registers a module once; every later call replays whatever the first one reported. */
151
+ /**
152
+ * Registers a module once; every later call replays whatever the first one reported — except for
153
+ * a route module, which a later call re-registers from disk when its source has changed.
154
+ * `snapshot` is the source read before the module's first import, and absent on every later call.
155
+ */
128
156
  async function register(
129
157
  absolute: string,
130
158
  file: string,
131
159
  module: Record<string, unknown>,
160
+ snapshot: string | undefined,
132
161
  ): Promise<Finding | undefined> {
133
162
  const previous = failures.get(absolute);
134
163
  if (previous !== undefined) return previous;
135
- if (registered.has(absolute)) return undefined;
164
+ if (snapshot === undefined) return reloadRoute(absolute, file);
136
165
  registered.add(absolute);
137
166
  try {
138
167
  const config = module['config'];
139
- if (isRouteConfig(config)) {
140
- // The build counts boundaries from the compiled JSX; before a build there is only the
141
- // source, and `render: 'stream'` is rejected without one — so count them in the text.
142
- const source = await Bun.file(absolute).text();
143
- // The page component comes from the same module as its config, resolved by render's own
144
- // rule — the CLI does not decide which export is a page any more than it decides what a
145
- // route is. A module with no component registers without one, and renders a bare shell.
146
- const component = pageComponentOf(module);
147
- registerRoute({
148
- file,
149
- config,
150
- suspenseBoundaries: countSuspense(source),
151
- ...(component === undefined ? {} : { component }),
152
- });
153
- }
168
+ const route = isRouteConfig(config) ? config : undefined;
169
+ if (route !== undefined) registerRouteModule(file, module, route, snapshot);
154
170
  registerActions(module);
155
171
  registerQueries(module);
172
+ // Only a route module is ever re-imported, so only a route module's hash is worth keeping.
173
+ if (route !== undefined) routeSources.set(absolute, Bun.hash.wyhash(snapshot));
156
174
  return undefined;
157
175
  } catch (error) {
158
176
  const finding: Finding = { ...findingFrom(error), at: file };
@@ -161,6 +179,59 @@ async function register(
161
179
  }
162
180
  }
163
181
 
182
+ /**
183
+ * The route half of a registration, and the whole of a re-registration. `source` is the text the
184
+ * module was imported from — read by the caller BEFORE the import, never here after it — and the
185
+ * hash the entry is bound to is that text's. Until 2026-09-07 this read the file again, and a save
186
+ * between the import and that read registered the old component under the new hash: the next
187
+ * scan compared equal, and the page on disk was not served until the save after it.
188
+ */
189
+ function registerRouteModule(
190
+ file: string,
191
+ module: Record<string, unknown>,
192
+ config: RouteConfig,
193
+ source: string,
194
+ ): void {
195
+ // The page component comes from the same module as its config, resolved by render's own
196
+ // rule — the CLI does not decide which export is a page any more than it decides what a
197
+ // route is. A module with no component registers without one, and renders a bare shell.
198
+ const component = pageComponentOf(module);
199
+ registerRoute({
200
+ file,
201
+ config,
202
+ // The build counts boundaries from the compiled JSX; before a build there is only the
203
+ // source, and `render: 'stream'` is rejected without one — so count them in the text.
204
+ suspenseBoundaries: countSuspense(source),
205
+ ...(component === undefined ? {} : { component }),
206
+ });
207
+ }
208
+
209
+ /**
210
+ * A registered route module, on a rescan: imported again if the file no longer hashes to what it
211
+ * registered from, and left alone otherwise. A save that will not import is a finding at the file
212
+ * and NOT a sticky one — the next save is tried again — and the entry it would have replaced stays
213
+ * registered, so the last page that did import is the one served meanwhile.
214
+ */
215
+ async function reloadRoute(absolute: string, file: string): Promise<Finding | undefined> {
216
+ const registeredFrom = routeSources.get(absolute);
217
+ if (registeredFrom === undefined) return undefined;
218
+ const source = await Bun.file(absolute).text();
219
+ const hash = Bun.hash.wyhash(source);
220
+ if (hash === registeredFrom) return undefined;
221
+ try {
222
+ const module = (await import(`${absolute}?${RELOAD_QUERY}=${hash}`)) as Record<string, unknown>;
223
+ const config = module['config'];
224
+ // A file that stopped being a route is not un-registered — the table has no verb for it, and a
225
+ // deleted file needs a restart either way. Its hash is recorded so the same save is not
226
+ // re-imported on every later tick.
227
+ if (isRouteConfig(config)) registerRouteModule(file, module, config, source);
228
+ routeSources.set(absolute, hash);
229
+ return undefined;
230
+ } catch (error) {
231
+ return { ...findingFrom(error), at: file };
232
+ }
233
+ }
234
+
164
235
  const countSuspense = (source: string): number => source.match(/<Suspense[\s/>]/g)?.length ?? 0;
165
236
 
166
237
  /** `packages/db/src/errors.ts` → `packages/db`; `apps/web/app/posts/errors.ts` → `apps/web`. */
package/src/budgets.ts CHANGED
@@ -227,9 +227,24 @@ export interface MeasuredJs {
227
227
  export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
228
228
  let jsBytes = 0;
229
229
  const entries: MeasuredEntry[] = [];
230
+ // Deduped ONCE, across both readers below, and the unit is the FETCH: a browser downloads a URL
231
+ // once however many times the document names it, so `budget.js` — a byte budget — counts it
232
+ // once. Two instances of one island are two wrappers and one chunk; so are a `<script src>`
233
+ // repeated by a page and its layout, and a src that is also an island entry. Only the island
234
+ // half was deduped, so a document naming one script twice was charged twice and could fail a
235
+ // budget it clears.
236
+ //
237
+ // EXECUTION is a different count and this is deliberately not it. A repeated classic
238
+ // `<script src>` runs once per element (a module runs once per document, off the module map), so
239
+ // the layout-plus-page case above really does execute twice — for zero extra bytes. That is a
240
+ // CPU cost, and this gate is a bound on what the browser downloads and parses. An INLINE script
241
+ // is not in this set for the same reason: two identical inline bodies are two copies of the
242
+ // bytes in the document, so both are charged.
243
+ const fetched = new Set<string>();
230
244
  const weigh = async (url: string): Promise<void> => {
231
245
  // Only a path inside the artifact can be weighed; a cross-origin script is not this build's.
232
- if (!url.startsWith('/')) return;
246
+ if (!url.startsWith('/') || fetched.has(url)) return;
247
+ fetched.add(url);
233
248
  const file = Bun.file(join(out, url.slice(1)));
234
249
  const bytes = (await file.exists()) ? file.size : 0;
235
250
  entries.push({ url, bytes });
@@ -246,13 +261,9 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
246
261
  }
247
262
  await weigh(src);
248
263
  }
249
- // Deduped: two instances of one island are two wrappers and one chunk, and a browser that
250
- // imports the same module twice fetches and executes it once.
251
- const booted = new Set<string>();
252
264
  for (const match of html.matchAll(ENTRY_ATTR)) {
253
265
  const url = match.groups?.['url'];
254
- if (url === undefined || booted.has(url)) continue;
255
- booted.add(url);
266
+ if (url === undefined) continue;
256
267
  await weigh(url);
257
268
  }
258
269
  return { jsBytes, entries };