@ultimat3/cli 19.1.3 → 19.3.1

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 (65) hide show
  1. package/CLAUDE.md +125 -8
  2. package/package.json +29 -29
  3. package/src/app-boundaries.ts +11 -2
  4. package/src/app-load.ts +5 -1
  5. package/src/app-openapi.ts +13 -5
  6. package/src/app-permissions.ts +0 -0
  7. package/src/browser-launcher.ts +53 -4
  8. package/src/budgets.ts +60 -7
  9. package/src/cmd-dev.ts +49 -39
  10. package/src/cmd-doctor.ts +61 -23
  11. package/src/cmd-generate.ts +5 -2
  12. package/src/cmd-i18n.ts +10 -3
  13. package/src/cmd-jobs.ts +56 -10
  14. package/src/cmd-shot.ts +3 -1
  15. package/src/cmd-test.ts +15 -10
  16. package/src/db-seed.ts +2 -1
  17. package/src/dev-queue.ts +16 -2
  18. package/src/dev-reload.ts +46 -0
  19. package/src/dev-render.ts +28 -7
  20. package/src/dev-roles.ts +9 -8
  21. package/src/dev-runtime.ts +4 -1
  22. package/src/dev-sync.ts +17 -3
  23. package/src/dev-watch-tree.ts +226 -0
  24. package/src/dev-watch.ts +75 -0
  25. package/src/doctor-offline.ts +122 -0
  26. package/src/duplicate-packages.ts +278 -0
  27. package/src/error-catalog.ts +4 -5
  28. package/src/error-codes.ts +6 -0
  29. package/src/fix-command.ts +40 -1
  30. package/src/fix-path.ts +10 -11
  31. package/src/flag-number.ts +15 -0
  32. package/src/generate-kinds.ts +54 -4
  33. package/src/generate-write.ts +25 -2
  34. package/src/gitignore.ts +145 -0
  35. package/src/hold.ts +50 -17
  36. package/src/i18n-registration.ts +34 -5
  37. package/src/index.ts +3 -1
  38. package/src/island-bundle.ts +123 -10
  39. package/src/island-harness.ts +11 -4
  40. package/src/island-states-load.ts +2 -1
  41. package/src/jobs-driver.ts +4 -1
  42. package/src/mcp-errors.ts +2 -0
  43. package/src/mcp-host.ts +21 -9
  44. package/src/parse.ts +17 -0
  45. package/src/path-segments.ts +14 -0
  46. package/src/prerender.ts +68 -16
  47. package/src/retry-memo.ts +37 -0
  48. package/src/serve.ts +17 -2
  49. package/src/shot-browser.ts +23 -4
  50. package/src/source-files.ts +3 -1
  51. package/src/static-report.ts +21 -1
  52. package/src/style-bundle.ts +124 -0
  53. package/src/style-csp.ts +14 -12
  54. package/src/style-routes.ts +56 -0
  55. package/src/sw-artifacts.ts +84 -12
  56. package/src/templates/admin-page.ts +49 -1
  57. package/src/templates/resource-form-island.ts +13 -3
  58. package/src/templates/scaffold-container.ts +12 -0
  59. package/src/templates/scaffold-repo.ts +13 -2
  60. package/src/test-passes.ts +79 -0
  61. package/src/test-shards.ts +110 -36
  62. package/src/verify-checks.ts +13 -7
  63. package/src/verify-step.ts +4 -4
  64. package/src/verify-tests.ts +33 -8
  65. package/src/web-binding.ts +22 -0
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`
@@ -156,6 +197,7 @@ them is answered by this table rather than by a second convention:
156
197
  | `x tasks` | `cmd-tasks.ts`, `tasks-facts.ts` | `registeredTasks()` + `@ultimat3/time`'s cron resolution |
157
198
  | `x policy` | `cmd-policy.ts`, `policy-facts.ts` | `@ultimat3/policy`'s `policyMatrix()` over the app's own `Policy` objects |
158
199
  | `x i18n` | `cmd-i18n.ts`, `i18n-audit.ts`, `i18n-registration.ts` | `@ultimat3/i18n`'s `extractFromFiles` + `auditCatalogs`, then the live catalog registry |
200
+ | `x i18n check` and the `policy` step, before either registry | `duplicate-packages.ts` | `Bun.resolveSync` of `@ultimat3/i18n` / `@ultimat3/policy` / `@ultimat3/entity` from the app root, every workspace and the CLI's own directory, keyed by REALPATH. Two real directories is `X_PACKAGE_DUPLICATED`: a registry is per module instance, so the app registers into one copy and the CLI reads the other — `x i18n check` said "move `defineCatalogs()`" about a call that was already where it belongs (ai-maxxing, 2026-09-05), and the policy step was green over an undeclared grant. A workspace symlink to one checkout is one copy; two store entries at ONE version are two |
159
201
 
160
202
  Each pairs a `cmd-*.ts` of CLI wiring with a facts module that takes plain inputs and returns plain
161
203
  data, so the projection is testable without a `ParsedArgs` — the `cmd-jobs.ts` / `jobs-report.ts`
@@ -405,7 +447,26 @@ cannot be missing from the precache manifest.
405
447
  |---|---|
406
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 |
407
449
  | `serve.ts` | the same two routes in the container, from the same call |
408
- | `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.
409
470
 
410
471
  **Registration is an EXTERNAL script, never inline**, and that is a CSP fact rather than a
411
472
  preference: `startWeb` computes a `script-src` sha256 per inline script, so an unhashed one is
@@ -981,7 +1042,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
981
1042
  | `sync-authenticator.ts` | the app's HTTP authenticator, seen as the sync node's |
982
1043
  | `otlp-export.ts` | the exporters `OTEL_EXPORTER_OTLP_ENDPOINT` switches on, and their drain hooks |
983
1044
  | `dev-render.ts` | one HTTP route per registered `route`, through render's own mode function |
984
- | `style-csp.ts` | the `style-src` sha256 of every inline `<style>` the web role serves |
1045
+ | `style-csp.ts` | the `style-src` sha256 of every inline `<style>` the web role **still** serves — the app's own surface CSS is a file under `/styles/` (`style-bundle.ts`) admitted by `'self'`, so a production boot extends the directive with nothing |
985
1046
  | `script-csp.ts` | the `script-src` sha256 of every inline `<script>` it serves — the hydration runtime, from `@ultimat3/render`'s own `HYDRATE_RUNTIME_BODIES` |
986
1047
  | `dev-assets.ts` | the image pipeline's only HTTP surface: `/icons/*` and `/media/*` |
987
1048
  | `favicon.ts` | `/favicon.ico`: the app's own file, and the bytes the framework answers with when there is none |
@@ -995,6 +1056,12 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
995
1056
  | `statement-loop.ts` | one verdict → the finding, the panel fact, the overlay notice and the log line |
996
1057
  | `dev-policy.ts` | which actors to ask about, and which capability each policy gates |
997
1058
  | `cmd-dev.ts` | boot order, mounting `/_x`, installing the span exporter, the file watcher |
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 |
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 |
998
1065
  | `mcp-host.ts` | the `DevCapabilities` half of `@ultimat3/mcp`'s `DevHost` — db, tests, logs, verify |
999
1066
  | `mcp-db-target.ts` | which database the host is pointed at: whether it is a branch, and whether it is production |
1000
1067
  | `mcp-errors.ts` | `errors.explain`: one runnable command per code, typed over `CliErrorCode` |
@@ -1002,6 +1069,44 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
1002
1069
  | `mcp-test-output.ts` | reading `bun test`'s own summary back into a `TestRun` |
1003
1070
  | `cmd-mcp.ts` | `x mcp serve`: the two transports, and the local developer's caller |
1004
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
+
1005
1110
  `api-routes.ts` is the app's own API surface, composed **once** and mounted by both `cmd-dev.ts`
1006
1111
  and `serve.ts`: `listActions().map(toRoute)` from `@ultimat3/action` plus
1007
1112
  `listQueries().map(toQueryRoute)` from `@ultimat3/query`. Two lists is how `query.client()`
@@ -1134,8 +1239,20 @@ island's `src` is a string, so no import edge reaches it and the page's graph st
1134
1239
  against bytes. Two islands that both import the same helper each carry a copy; that is the honest
1135
1240
  number for what booting either one costs.
1136
1241
 
1137
- **The chunk URL is content-addressed with render's own `contentHash`** — the function that already
1138
- stamps an ETag and a precache revision. One identity for a byte string, not a third.
1242
+ **The chunk URL is SOURCE-addressed, not byte-addressed, `As of 2026-09-06`.** `graphHash` hashes
1243
+ the build's inputs — the source map's `sourcesContent`, sorted, plus the entry's app-relative path,
1244
+ the framework version and `Bun.version` — with render's own `contentHash`, the function that already
1245
+ stamps an ETag and a precache revision. Hashing the OUTPUT is what it did, and `Bun.build` is not
1246
+ byte-deterministic under `minify`: measured on 1.4.0, one entry point, no source file touched, a
1247
+ 131,589-byte island alternated between two outputs of identical length differing only in minified
1248
+ identifier names, roughly one build in ten. The URL then flapped — ten names in ten minutes in
1249
+ ai-maxxing — so the service worker precached a chunk that 404ed and the `immutable` cache never hit.
1250
+ The cost of the alternative was measured and refused: `minify: { identifiers: false }` IS
1251
+ deterministic and is +47% raw, +20% gzipped, on every island of every app. `sourcemap: 'external'`
1252
+ pays for the input list (277ms against 276ms) and its `//# debugId=` line is stripped, so the
1253
+ shipped bytes are unchanged. Two processes can therefore serve two byte strings at one URL — the
1254
+ same program under different local names — and a per-file cache keeps ONE process serving one.
1255
+ Delete `graphHash` the day `Bun.build` is deterministic.
1139
1256
 
1140
1257
  **`x dev`, the container and the static export all mount the same table.** `serve.ts` builds the
1141
1258
  islands at boot for the same reason it mounts `apiRoutes()`: a seam that works in dev and not in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "19.1.3",
3
+ "version": "19.3.1",
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.1.3",
41
- "@ultimat3/admin": "19.1.3",
42
- "@ultimat3/ai": "19.1.3",
43
- "@ultimat3/auth": "19.1.3",
44
- "@ultimat3/cache": "19.1.3",
45
- "@ultimat3/core": "19.1.3",
46
- "@ultimat3/db": "19.1.3",
47
- "@ultimat3/entity": "19.1.3",
48
- "@ultimat3/flags": "19.1.3",
49
- "@ultimat3/http": "19.1.3",
50
- "@ultimat3/i18n": "19.1.3",
51
- "@ultimat3/jobs": "19.1.3",
52
- "@ultimat3/mail": "19.1.3",
53
- "@ultimat3/manifest": "19.1.3",
54
- "@ultimat3/mcp": "19.1.3",
55
- "@ultimat3/money": "19.1.3",
56
- "@ultimat3/notify": "19.1.3",
57
- "@ultimat3/policy": "19.1.3",
58
- "@ultimat3/pwa": "19.1.3",
59
- "@ultimat3/query": "19.1.3",
60
- "@ultimat3/realtime": "19.1.3",
61
- "@ultimat3/render": "19.1.3",
62
- "@ultimat3/schema": "19.1.3",
63
- "@ultimat3/scraping": "19.1.3",
64
- "@ultimat3/seo": "19.1.3",
65
- "@ultimat3/storage": "19.1.3",
66
- "@ultimat3/testing": "19.1.3",
67
- "@ultimat3/time": "19.1.3",
40
+ "@ultimat3/action": "19.3.1",
41
+ "@ultimat3/admin": "19.3.1",
42
+ "@ultimat3/ai": "19.3.1",
43
+ "@ultimat3/auth": "19.3.1",
44
+ "@ultimat3/cache": "19.3.1",
45
+ "@ultimat3/core": "19.3.1",
46
+ "@ultimat3/db": "19.3.1",
47
+ "@ultimat3/entity": "19.3.1",
48
+ "@ultimat3/flags": "19.3.1",
49
+ "@ultimat3/http": "19.3.1",
50
+ "@ultimat3/i18n": "19.3.1",
51
+ "@ultimat3/jobs": "19.3.1",
52
+ "@ultimat3/mail": "19.3.1",
53
+ "@ultimat3/manifest": "19.3.1",
54
+ "@ultimat3/mcp": "19.3.1",
55
+ "@ultimat3/money": "19.3.1",
56
+ "@ultimat3/notify": "19.3.1",
57
+ "@ultimat3/policy": "19.3.1",
58
+ "@ultimat3/pwa": "19.3.1",
59
+ "@ultimat3/query": "19.3.1",
60
+ "@ultimat3/realtime": "19.3.1",
61
+ "@ultimat3/render": "19.3.1",
62
+ "@ultimat3/schema": "19.3.1",
63
+ "@ultimat3/scraping": "19.3.1",
64
+ "@ultimat3/seo": "19.3.1",
65
+ "@ultimat3/storage": "19.3.1",
66
+ "@ultimat3/testing": "19.3.1",
67
+ "@ultimat3/time": "19.3.1",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -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
@@ -21,6 +21,7 @@ import '@ultimat3/render/server';
21
21
  import { collectDeclaredCodes } from './error-contract';
22
22
  import type { Finding } from './output';
23
23
  import { findingFrom } from './output';
24
+ import { hasPathSegment } from './path-segments';
24
25
 
25
26
  /** Every place an app keeps code the framework has to see. */
26
27
  const APP_GLOBS = [
@@ -95,7 +96,10 @@ export async function loadApp(root: string): Promise<LoadedApp> {
95
96
 
96
97
  for (const pattern of APP_GLOBS) {
97
98
  for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
98
- if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
99
+ // A SEGMENT, never a substring: an app checked out under
100
+ // `~/dev/node_modules-experiments/myapp` answered `includes('node_modules')` for every
101
+ // file it holds, so this loop imported none of them and the app registered nothing.
102
+ if (hasPathSegment(absolute, 'node_modules') || absolute.includes('.test.')) continue;
99
103
  const file = relative(root, absolute).split(sep).join('/');
100
104
  if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
101
105
  continue;
@@ -1,12 +1,20 @@
1
- // `openapi.json`, projected from the action registry by `@ultimat3/action`. The CLI writes the
2
- // file and compares the bytes; it does not know how an operation is shaped, which is why there
3
- // is no second OpenAPI builder to drift from the one the server serves.
1
+ // `openapi.json`, projected from the two registries: the actions by `@ultimat3/action`'s
2
+ // `buildOpenApi`, the queries by `@ultimat3/query`'s `queryOpenApiPaths`. The CLI merges the two
3
+ // `paths` maps — those packages are one tier and cannot compose each other — writes the file and
4
+ // compares the bytes; it does not know how an operation is shaped, which is why there is no third
5
+ // OpenAPI builder to drift from the ones the packages serve. Until 2026-09 only the actions were
6
+ // here, and every `GET /_x/query/<name>` the server mounted was a route the spec had never heard of.
4
7
 
5
8
  import { buildOpenApi, serializeOpenApi } from '@ultimat3/action';
6
9
  import type { Manifest } from '@ultimat3/manifest';
10
+ import { queryOpenApiPaths } from '@ultimat3/query';
7
11
 
8
12
  export const OPENAPI_FILE = 'openapi.json';
9
13
 
10
14
  /** The exact bytes on disk — deterministic, so `x verify` can compare them literally. */
11
- export const openApiJson = (manifest: Manifest): string =>
12
- serializeOpenApi(buildOpenApi({ title: manifest.app.name, version: manifest.app.version }));
15
+ export const openApiJson = (manifest: Manifest): string => {
16
+ const document = buildOpenApi({ title: manifest.app.name, version: manifest.app.version });
17
+ // Action paths are `/api/...`, query paths `/_x/query/...`: disjoint by prefix, so the spread
18
+ // can never shadow one with the other. `serializeOpenApi` sorts the merged keys.
19
+ return serializeOpenApi({ ...document, paths: { ...document.paths, ...queryOpenApiPaths() } });
20
+ };
Binary file
@@ -13,6 +13,7 @@ import { existsSync } from 'node:fs';
13
13
  import { UltimateError } from '@ultimat3/core';
14
14
  import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
15
15
  import { localBrowser, remoteBrowser } from '@ultimat3/scraping';
16
+ import { CHROME_CANDIDATES } from './cdp-launch';
16
17
 
17
18
  /**
18
19
  * The one library this works against. Playwright is not an alternative and is not a flag:
@@ -56,6 +57,33 @@ export class ShotBrowserMissingError extends UltimateError {
56
57
  }
57
58
  }
58
59
 
60
+ /**
61
+ * The OTHER half of "no browser": the library is installed and there is no Chrome for it to start.
62
+ *
63
+ * The same code as the class above because it is the same question to a reader — `x shot` has no
64
+ * browser — and a code is a stable public name, not a taxonomy. The cause and the fix are what
65
+ * differ, and they are the half that is acted on: `bun add -d puppeteer-core` cannot install
66
+ * Chrome, and `export CHROME_PATH=…` cannot install a client that speaks CDP.
67
+ *
68
+ * Raised BEFORE the dev server, which is the whole point of it: the launch that used to report this
69
+ * happens inside `driver.open()`, one embedded Postgres past the point where the answer was already
70
+ * decidable from the environment and the filesystem.
71
+ */
72
+ export class ShotChromeMissingError extends UltimateError {
73
+ constructor() {
74
+ super({
75
+ code: 'X_SHOT_BROWSER_MISSING',
76
+ cause: `x shot launches a browser here and none was named or found — no ${BROWSER_PATH_VARS.join(' or ')} is set, and none of ${CHROME_CANDIDATES.join(', ')} is on disk`,
77
+ // One literal, for `fix-scan.ts`'s reason — the same rule the class above states. It names an
78
+ // export rather than an install because a path is the one repair that works whatever the
79
+ // browser is and wherever the distribution put it; `--cdp-url` is the answer for a box that
80
+ // will never have one, and it is a flag `x shot` already ships.
81
+ fix: 'export CHROME_PATH=/usr/bin/google-chrome # any Chrome or Chromium binary; a box that will never have one attaches instead: x shot / --cdp-url wss://cdp.example.com/session/abc',
82
+ meta: { tried: [...CHROME_CANDIDATES], vars: [...BROWSER_PATH_VARS] },
83
+ });
84
+ }
85
+ }
86
+
59
87
  /**
60
88
  * Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
61
89
  * transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
@@ -99,22 +127,43 @@ export interface AppBrowserOptions {
99
127
  readonly load?: (path: string) => Promise<unknown>;
100
128
  }
101
129
 
102
- /** The path a run will launch, or `undefined` for "let the library find its own". */
130
+ /** True when a named executable is really there — a bad `--browser` is refused before a boot. */
131
+ export const browserBinaryExists = (path: string): boolean => existsSync(path);
132
+
133
+ /**
134
+ * The path a run will launch, or `undefined` when this machine has no browser to launch.
135
+ *
136
+ * NOT "let the library find its own": `puppeteer-core` ships no browser and has no default, so a
137
+ * launch with no `executablePath` throws ``An `executablePath` or `channel` must be specified``
138
+ * from inside somebody else's library, one dev-server boot after the point where the answer was
139
+ * knowable. So the last step is a PROBE — the same four paths `cdp-launch.ts` already tries for the
140
+ * e2e driver, imported rather than restated, because two lists of Chrome locations that must agree
141
+ * is the drift axiom 2 refuses.
142
+ *
143
+ * A value NAMED in the flag or the environment is answered without touching the filesystem, even
144
+ * when nothing is there: an operator who typed a path has a belief about which binary runs, and
145
+ * silently substituting a probed one would photograph a page in a browser they did not choose.
146
+ * `shotBrowserChoice` reports that path as absent instead.
147
+ *
148
+ * `exists` is the injectable seam, so the probe is asserted the same way on a machine with Chrome
149
+ * and on one without.
150
+ */
103
151
  export const executablePathFrom = (
104
152
  flag: string | undefined,
105
153
  env: Readonly<Record<string, string | undefined>>,
154
+ exists: (path: string) => boolean = browserBinaryExists,
106
155
  ): string | undefined => {
107
156
  if (flag !== undefined && flag.length > 0) return flag;
108
157
  for (const name of BROWSER_PATH_VARS) {
109
158
  const value = env[name];
110
159
  if (value !== undefined && value.length > 0) return value;
111
160
  }
161
+ for (const candidate of CHROME_CANDIDATES) {
162
+ if (exists(candidate)) return candidate;
163
+ }
112
164
  return undefined;
113
165
  };
114
166
 
115
- /** True when a named executable is really there — a bad `--browser` is refused before a boot. */
116
- export const browserBinaryExists = (path: string): boolean => existsSync(path);
117
-
118
167
  /** The endpoint a run will attach to, or `undefined` for "launch one here". */
119
168
  export const cdpUrlFrom = (
120
169
  flag: string | undefined,
package/src/budgets.ts CHANGED
@@ -12,6 +12,7 @@ import { ERROR_DOCS_URL } from '@ultimat3/core';
12
12
  import type { Manifest, RouteFact } from '@ultimat3/manifest';
13
13
  import { formatBytes, parseByteBudget } from '@ultimat3/render';
14
14
  import type { Finding } from './output';
15
+ import type { UnmeasuredRoute } from './static-report';
15
16
 
16
17
  export const BUILD_STATS_FILE = join('.x', 'build-stats.json');
17
18
 
@@ -87,14 +88,52 @@ function unmeasuredFinding(url: string, declared: string, built: boolean): Findi
87
88
  };
88
89
  }
89
90
 
91
+ /**
92
+ * The ONE code a failed measurement is reported under by its own name rather than as
93
+ * `X_BUDGET_UNMEASURED`. Its cause is complete — the island, the prop, its bytes, the cap — and
94
+ * its fix is an edit to the page, so the step's own "run x build and read the list" would put a
95
+ * second command between the author and a sentence the build had already composed. Every other
96
+ * render failure keeps the generic finding: a `TypeError` from a `load` that wanted a request is
97
+ * a reason to read the report, not an instruction.
98
+ *
99
+ * ONE code and not "any coded error", deliberately. `X_NO_CONTEXT`, `X_UNAUTHENTICATED` and
100
+ * `X_DB_UNAVAILABLE` from a measurement render are facts about the BUILD's environment, and
101
+ * reporting them under their own codes would tell the author to fix a database the gate never
102
+ * had. The list grows by a decision, per code, here.
103
+ */
104
+ const REPORTED_BY_OWN_CODE: ReadonlySet<string> = new Set(['X_ISLAND_PROPS_INVALID']);
105
+
106
+ /**
107
+ * The build's own finding for a route it could not weigh, when that failure is an instruction.
108
+ * Read off the static report's `unmeasured` list — the same list `X_BUDGET_UNMEASURED`'s `fix:`
109
+ * sends its reader to, now read by the step itself for the one code it can act on.
110
+ */
111
+ function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Finding | undefined {
112
+ const entry = unmeasured.find((one) => one.path === url);
113
+ if (entry?.code === undefined || !REPORTED_BY_OWN_CODE.has(entry.code)) return undefined;
114
+ return {
115
+ code: entry.code,
116
+ cause: entry.cause ?? entry.reason,
117
+ fix: entry.fix ?? `x build --target static --json # its "unmeasured" list has ${url}`,
118
+ docs: ERROR_DOCS_URL,
119
+ at: url,
120
+ };
121
+ }
122
+
90
123
  /**
91
124
  * `undefined` stats means no build has run; `{ routes: [] }` means one ran and emitted nothing.
92
125
  * The parameter is widened rather than defaulted, because collapsing the two here is exactly the
93
126
  * distinction the finding above exists to make.
127
+ *
128
+ * `unmeasured` is the static report's list of routes the build rendered and could not weigh, with
129
+ * each failure's code when it had one. Optional because the report is written beside the stats
130
+ * and can be absent for the same reason; with it, a route whose measurement failed on a code in
131
+ * `REPORTED_BY_OWN_CODE` is reported under that code, with the build's own cause and fix.
94
132
  */
95
133
  export function checkBudgets(
96
134
  manifest: Manifest,
97
135
  stats: BuildStats | undefined,
136
+ unmeasured: readonly UnmeasuredRoute[] = [],
98
137
  ): readonly Finding[] {
99
138
  const byPath = new Map((stats?.routes ?? []).map((route) => [route.path, route]));
100
139
  const findings: Finding[] = [];
@@ -104,7 +143,10 @@ export function checkBudgets(
104
143
  const lcp = route.budget?.lcp;
105
144
  if (measured === undefined) {
106
145
  if (js !== null || lcp !== undefined) {
107
- findings.push(unmeasuredFinding(route.url, declaredBudgets(js, lcp), stats !== undefined));
146
+ findings.push(
147
+ ownCodeFinding(route.url, unmeasured) ??
148
+ unmeasuredFinding(route.url, declaredBudgets(js, lcp), stats !== undefined),
149
+ );
108
150
  }
109
151
  continue;
110
152
  }
@@ -185,9 +227,24 @@ export interface MeasuredJs {
185
227
  export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
186
228
  let jsBytes = 0;
187
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>();
188
244
  const weigh = async (url: string): Promise<void> => {
189
245
  // Only a path inside the artifact can be weighed; a cross-origin script is not this build's.
190
- if (!url.startsWith('/')) return;
246
+ if (!url.startsWith('/') || fetched.has(url)) return;
247
+ fetched.add(url);
191
248
  const file = Bun.file(join(out, url.slice(1)));
192
249
  const bytes = (await file.exists()) ? file.size : 0;
193
250
  entries.push({ url, bytes });
@@ -204,13 +261,9 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
204
261
  }
205
262
  await weigh(src);
206
263
  }
207
- // Deduped: two instances of one island are two wrappers and one chunk, and a browser that
208
- // imports the same module twice fetches and executes it once.
209
- const booted = new Set<string>();
210
264
  for (const match of html.matchAll(ENTRY_ATTR)) {
211
265
  const url = match.groups?.['url'];
212
- if (url === undefined || booted.has(url)) continue;
213
- booted.add(url);
266
+ if (url === undefined) continue;
214
267
  await weigh(url);
215
268
  }
216
269
  return { jsBytes, entries };