@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
@@ -0,0 +1,46 @@
1
+ // One rebuild at a time. A reload is a full `appManifest()` plus a `buildIslands()` over every
2
+ // island, and the watcher had no in-flight guard: a 45ms drip of writes — a slow `git checkout`, a
3
+ // formatter walking the tree, `x db gen` — started one per file, 40 for 40 files, each assigning
4
+ // the same two state slots in COMPLETION order. So a slower earlier rebuild could land on top of a
5
+ // newer one, and the dev server then served a manifest built from source that had already changed.
6
+
7
+ /** What a tick does while a rebuild is already running: nothing, except become the next one. */
8
+ export type ReloadTrigger = (file: string) => void;
9
+
10
+ /**
11
+ * Serialise `run`, keeping only the LAST tick that arrived while it was busy. N ticks during one
12
+ * rebuild are exactly one more rebuild, for the newest file — never N queued ones, and never a
13
+ * dropped tick, which would leave the process serving the state before the author's last save.
14
+ *
15
+ * `onError` defaults to swallowing, because the caller that cares reports its own failure as a
16
+ * finding; a rejection escaping here would be an unhandled rejection that takes `x dev` down.
17
+ */
18
+ export function coalesceReloads(
19
+ run: (file: string) => Promise<void> | void,
20
+ onError: (error: unknown, file: string) => void = () => undefined,
21
+ ): ReloadTrigger {
22
+ let running = false;
23
+ let pending: string | undefined;
24
+
25
+ const start = (file: string): void => {
26
+ running = true;
27
+ // `Promise.resolve().then` rather than a bare call: a SYNCHRONOUS throw from `run` would
28
+ // otherwise escape the fs callback that triggered it, where nothing is listening.
29
+ void (async () => await run(file))()
30
+ .catch((error: unknown) => onError(error, file))
31
+ .finally(() => {
32
+ running = false;
33
+ const next = pending;
34
+ pending = undefined;
35
+ if (next !== undefined) start(next);
36
+ });
37
+ };
38
+
39
+ return (file: string): void => {
40
+ if (running) {
41
+ pending = file;
42
+ return;
43
+ }
44
+ start(file);
45
+ };
46
+ }
package/src/dev-render.ts CHANGED
@@ -3,9 +3,16 @@
3
3
  // document, it never decides what a mode means or what headers it earns.
4
4
  //
5
5
  // The document is head + the route's own component, rendered by `@ultimat3/render`'s server JSX
6
- // writer, with the surface's compiled CSS inlined. Inlined rather than linked because a `site/`
7
- // page is a 0kb-JS artifact a CDN serves as one file: a stylesheet link would add a round trip to
8
- // the render path the mode exists to make cheap, and a static export would need a second file.
6
+ // writer, with the surface's compiled CSS LINKED — one content-hashed file per surface
7
+ // (`style-bundle.ts`), served `immutable`.
8
+ //
9
+ // It was inlined until 2026-09-06, on the argument that a `site/` page is a 0kb-JS artifact a CDN
10
+ // serves as one file and a link would add a round trip. The round trip is real and it is paid
11
+ // once: measured against ai-maxxing, every `app/` document carried the SAME 156,738-byte `<style>`
12
+ // block — 92% of the dashboard document — inside a response the pipeline sends
13
+ // `Cache-Control: private, no-store`, so the trip that argument saved was re-paid in full on every
14
+ // navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
15
+ // "second file" cost is one `Bun.write`.
9
16
 
10
17
  import type { Ctx } from '@ultimat3/core';
11
18
  import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
@@ -32,8 +39,8 @@ import {
32
39
  renderSsr,
33
40
  staticHeaders,
34
41
  streamResult,
35
- stylesFor,
36
42
  } from '@ultimat3/render/server';
43
+ import { styleBundle } from './style-bundle';
37
44
 
38
45
  /**
39
46
  * Specifier → built chunk URL, bound to the route file the specifier is written relative to.
@@ -92,10 +99,24 @@ const headFor = async (
92
99
  ),
93
100
  ) + (options.pwaHead ?? '');
94
101
 
95
- /** `<style>` for the surface's own stylesheets, or nothing at all when the surface imports none. */
102
+ /**
103
+ * `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
104
+ * imports none.
105
+ *
106
+ * In `<head>`, which is what keeps this a swap and not a regression: a `<link rel="stylesheet">`
107
+ * there is render-blocking in every browser, exactly as the inline block was, so there is no
108
+ * window in which the document paints unstyled. Moving it to the body, or deferring it, is what
109
+ * would introduce a flash — never do that here.
110
+ *
111
+ * Read through `styleBundle()` rather than passed in through `DocumentOptions`: the registry it
112
+ * derives from is process-global (importing the app IS what fills it), so a caller that forgot to
113
+ * thread a resolver would serve a document with no CSS at all — and `appRoutes` is on this
114
+ * package's public surface, called as `appRoutes({ buildId })` by both tracked apps' contract
115
+ * tests. One reader, and it is the same one `styleRoutes` serves from.
116
+ */
96
117
  const styleTag = (entry: RouteEntry): string => {
97
- const css = stylesFor(entry.surface);
98
- return css.length === 0 ? '' : `<style>${css}</style>`;
118
+ const href = styleBundle().hrefFor(entry.surface);
119
+ return href === undefined ? '' : `<link rel="stylesheet" href="${href}">`;
99
120
  };
100
121
 
101
122
  /**
package/src/dev-roles.ts CHANGED
@@ -43,6 +43,7 @@ import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
43
43
  import type { RuntimeOverrides } from './runtime-overrides';
44
44
  import { inlineScriptSources } from './script-csp';
45
45
  import { inlineStyleSources } from './style-csp';
46
+ import { DEV_BINDING, type WebBinding } from './web-binding';
46
47
 
47
48
  /** The roles `x dev` starts when `--role` names none, in boot order. */
48
49
  export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
@@ -113,13 +114,9 @@ export interface StartRolesOptions {
113
114
  readonly overrides?: RuntimeOverrides;
114
115
  }
115
116
 
116
- export interface WebBinding {
117
- readonly dev: boolean;
118
- readonly hostname: string;
119
- }
120
-
121
- /** Loopback and dev-mode. What `x dev` means, and what a container must override. */
122
- export const DEV_BINDING: WebBinding = { dev: true, hostname: 'localhost' };
117
+ // Re-exported, not re-declared: `web-binding.ts` is a leaf so `dev-sync` can read the default
118
+ // without importing this module, which imports it.
119
+ export { DEV_BINDING, type WebBinding } from './web-binding';
123
120
 
124
121
  export interface RunningRoles {
125
122
  readonly roles: readonly Role[];
@@ -366,9 +363,13 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
366
363
  // First, and for every role rather than only the two that open an HTTP socket: `worker` and
367
364
  // `sync` are precisely the roles whose HPAs read a series the process itself has to publish,
368
365
  // and a `worker` container with no listener is an HPA pinned at `<unknown>` forever.
366
+ // `?? DEV_BINDING`, not "omit the key when `options.http` is undefined". The old spread left
367
+ // the metrics endpoint on Bun's `0.0.0.0` for exactly the caller that asked for loopback —
368
+ // `x dev`, which passes no `http` at all — so the same gap the sync node had was here too.
369
+ const binding = options.http ?? DEV_BINDING;
369
370
  const metrics = startMetricsEndpoint({
370
371
  port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
371
- ...(options.http === undefined ? {} : { hostname: options.http.hostname }),
372
+ hostname: binding.hostname,
372
373
  });
373
374
  started.push(async () => metrics.stop());
374
375
 
@@ -299,7 +299,10 @@ export async function startServices(
299
299
  // `@ultimat3/realtime`'s decision, and it is the same call a `ROLE=sync` container makes, so this
300
300
  // process cannot resolve the bus differently from the container it stands in for.
301
301
  const bus: TransportSelection = selectTransport(env);
302
- const queue = await startQueue(services, overrides);
302
+ // `env`, not the ambient one: this function is HANDED the boot's environment and every other
303
+ // reader here already uses it, so a queue that asked `process.env` would decide the standby from
304
+ // a different answer than the middleware that routes to it.
305
+ const queue = await startQueue(services, overrides, env);
303
306
  const { db, jobs, outbox, events } = queue;
304
307
  // The same executor the jobs driver, the outbox, the event bus and the idempotency store run
305
308
  // on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
package/src/dev-sync.ts CHANGED
@@ -15,9 +15,10 @@ import {
15
15
  SocketRegistry,
16
16
  } from '@ultimat3/realtime/server';
17
17
  import type { StartRolesOptions } from './dev-roles';
18
- import { neighbouringPort, PORT_RANGE } from './flag-number';
18
+ import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
19
19
  import { portFree } from './port-probe';
20
20
  import { syncAuthenticator } from './sync-authenticator';
21
+ import { DEV_BINDING } from './web-binding';
21
22
 
22
23
  /**
23
24
  * Beside its one thrower rather than in `errors.ts`, which is at 461 of the 500-line ceiling —
@@ -52,7 +53,11 @@ class SyncPortInUseError extends UltimateError {
52
53
  super({
53
54
  code: 'X_PORT_IN_USE',
54
55
  cause: `the sync role binds PORT + 1, so \`x dev --port ${input.webPort}\` needs port ${input.port} and something is already listening on it`,
55
- fix: `x dev --port ${neighbouringPort(input.webPort)} # or free port ${input.port}: lsof -nP -iTCP:${input.port} -sTCP:LISTEN`,
56
+ // `portPairAfter`, never `neighbouringPort`: `x dev` binds a PAIR, so the neighbour of the
57
+ // web port IS the sync port this refusal is about — the fix said `x dev --port 4000` for a
58
+ // run that had just died on 4000, and a test named "its fix is a command that ends the
59
+ // failure" pinned it.
60
+ fix: `x dev --port ${portPairAfter(input.webPort)} # or free port ${input.port}: lsof -nP -iTCP:${input.port} -sTCP:LISTEN`,
56
61
  meta: { port: input.port, webPort: input.webPort },
57
62
  });
58
63
  }
@@ -177,7 +182,16 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
177
182
  await node.start();
178
183
  const port = syncPortFor(options.port);
179
184
  try {
180
- const listener = listenSyncNode(node, { port });
185
+ // The SAME interface the web role binds, resolved from the same option and the same default.
186
+ // Without this the sync node took Bun's `0.0.0.0` while `x dev`'s web role took `localhost`,
187
+ // so the one socket that streams live database patches was the one socket on every
188
+ // interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
189
+ const binding = options.http ?? DEV_BINDING;
190
+ // No drain grace: there is one node here and it is the one going away. Its clients reconnect
191
+ // to it when `x dev` is back, and a grace that kept their patches flowing meanwhile was five
192
+ // seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a reconnect frame
193
+ // whose target does not exist yet.
194
+ const listener = listenSyncNode(node, { port, hostname: binding.hostname, drainGraceMs: 0 });
181
195
  return {
182
196
  url: listener.url,
183
197
  registry,
@@ -0,0 +1,226 @@
1
+ // The app root watched one directory at a time, because the watch SET is a registration decision
2
+ // and not a filter. `watch(root, { recursive: true })` takes an inotify descriptor per directory
3
+ // in the tree — including every directory the dev loop then discards events from — so `.git/`,
4
+ // `node_modules/` and `.x/` (which this very process writes to, continuously) each cost a
5
+ // descriptor, a kernel queue entry and a JS callback per write, against a per-user descriptor
6
+ // budget of 8192 on many distributions. Bun 1.4.0's `fs.watch` takes no ignore option, so the
7
+ // only place the answer can be given early is at registration.
8
+
9
+ // why: Bun exposes no filesystem watcher, no directory listing and no synchronous stat —
10
+ // `Bun.file().exists()` is async and answers false for a DIRECTORY, which is the one thing this
11
+ // module has to decide. Delete each when Bun ships an equivalent.
12
+ import type { Dirent } from 'node:fs';
13
+ import { readdirSync, statSync, watch } from 'node:fs';
14
+ // why: Bun exposes no path-join primitive.
15
+ import { join } from 'node:path';
16
+ import { finiteCount, logger } from '@ultimat3/core';
17
+ import type { DevIgnore } from './dev-watch';
18
+ import { devIgnore } from './dev-watch';
19
+ import { pathSegments } from './path-segments';
20
+
21
+ /** What `node:fs`'s watcher hands a listener — `filename` is optional in fact, not only in type. */
22
+ export type WatchListener = (event: string, filename: string | Buffer | null | undefined) => void;
23
+
24
+ /** The one thing this module needs of a watcher, so a test can stand one up in four lines. */
25
+ export interface DirectoryWatcher {
26
+ close(): void;
27
+ }
28
+
29
+ export type WatchDirectory = (directory: string, listener: WatchListener) => DirectoryWatcher;
30
+
31
+ export interface WatchTreeOptions {
32
+ readonly root: string;
33
+ /** A source change, app-root-relative and POSIX-separated. Debounced. */
34
+ readonly onChange: (file: string) => void;
35
+ /** Trailing debounce. A save touching five files is one reload, not five. Default 30ms. */
36
+ readonly debounceMs?: number;
37
+ /** Test seam: the default is `node:fs`'s `watch(dir, { recursive: false })`. */
38
+ readonly watchDirectory?: WatchDirectory;
39
+ }
40
+
41
+ export interface WatchTree {
42
+ /** Directories holding a descriptor right now, app-root-relative; `''` is the app root. */
43
+ directories(): readonly string[];
44
+ close(): void;
45
+ }
46
+
47
+ const DEFAULT_DEBOUNCE_MS = 30;
48
+
49
+ /**
50
+ * Every directory under `root` an event could be a source change in, `''` first. Ignored
51
+ * directories are not descended, so a `node_modules` tree costs one `readdir` and nothing else.
52
+ * A symlinked directory is deliberately not followed: `Dirent.isDirectory()` is false for one, and
53
+ * a workspace symlink pointing back into the checkout would otherwise be walked twice.
54
+ */
55
+ export function admittedDirectories(root: string, ignore: DevIgnore): readonly string[] {
56
+ const admitted: string[] = [''];
57
+ const pending: string[] = [''];
58
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
59
+ for (const entry of childrenOf(join(root, next))) {
60
+ if (!entry.isDirectory()) continue;
61
+ const child = next === '' ? entry.name : `${next}/${entry.name}`;
62
+ if (ignore.ignores(child, true)) continue;
63
+ admitted.push(child);
64
+ pending.push(child);
65
+ }
66
+ }
67
+ return admitted.sort();
68
+ }
69
+
70
+ /**
71
+ * One directory's entries, or none. A directory removed between the listing above and this read, or
72
+ * one this user may not open, is neither a source change nor a finding anyone can act on.
73
+ */
74
+ function childrenOf(path: string): readonly Dirent[] {
75
+ try {
76
+ return readdirSync(path, { withFileTypes: true });
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ const nodeWatch: WatchDirectory = (directory, listener) =>
83
+ watch(directory, { recursive: false }, listener);
84
+
85
+ /** Whether the path is a directory right now — the question a `rename` event does not answer. */
86
+ function isDirectoryAt(path: string): boolean {
87
+ try {
88
+ return statSync(path).isDirectory();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ export function watchTree(options: WatchTreeOptions): WatchTree {
95
+ const { root, onChange } = options;
96
+ // Screened before a single descriptor is taken. `??` guards NULLISH and `NaN` is not nullish, so
97
+ // an unparsed value walks past the default into `setTimeout(fn, NaN)`, which coerces to **0**:
98
+ // the debounce reads as installed and every keystroke runs a full `appManifest()` plus
99
+ // `buildIslands()`. `0` is admitted — "rebuild on the next tick" is a decision — and `Infinity`
100
+ // is not, because a reload that never fires is the same defect facing the other way.
101
+ const debounceMs = finiteCount(
102
+ 'watchTree',
103
+ 'debounceMs',
104
+ options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
105
+ );
106
+ const open = new WatchDirectoryMap(options.watchDirectory ?? nodeWatch, root);
107
+ let ignore = devIgnore(root);
108
+ let timer: ReturnType<typeof setTimeout> | undefined;
109
+ let last = '';
110
+ let warned = false;
111
+
112
+ const schedule = (file: string): void => {
113
+ last = file;
114
+ if (timer !== undefined) clearTimeout(timer);
115
+ timer = setTimeout(() => onChange(last), debounceMs);
116
+ };
117
+
118
+ const listen = (directory: string): void => {
119
+ open.add(directory, (event, filename) => {
120
+ if (typeof filename !== 'string' && !(filename instanceof Buffer)) {
121
+ // Bun's watcher delivers no filename when the WATCHED directory itself moves or is removed
122
+ // — `mv myapp myapp2`, a re-clone, a volume remount. Once, because the same rename can
123
+ // arrive on every descriptor at the same instant.
124
+ if (!warned) logger.warn('dev.watch.unnamed_event', { directory: directory || '.' });
125
+ warned = true;
126
+ return;
127
+ }
128
+ const name = pathSegments(filename.toString()).join('/');
129
+ const file = directory === '' ? name : `${directory}/${name}`;
130
+ // Asked of the DISK, once, because every trailing-slash rule in a `.gitignore` turns on it
131
+ // and a `rename` says only that something moved. One stat against a whole rebuild.
132
+ const isDirectory = open.has(file) || isDirectoryAt(join(root, file));
133
+ if (event === 'rename') follow(file, isDirectory);
134
+ if (file === '.gitignore') {
135
+ // The ignore set is the app's own, so an edit to it changes which directories are watched
136
+ // at all. Not a source change: rebuilding the manifest for it would be a reload the author
137
+ // did not ask for on the one file whose whole job is saying what to leave alone.
138
+ ignore = devIgnore(root);
139
+ reconcile();
140
+ return;
141
+ }
142
+ if (ignore.ignores(file, isDirectory)) return;
143
+ schedule(file);
144
+ });
145
+ };
146
+
147
+ /** A `rename` created or removed something: keep the descriptor set honest either way. */
148
+ const follow = (file: string, isDirectory: boolean): void => {
149
+ if (isDirectory && isDirectoryAt(join(root, file))) {
150
+ if (ignore.ignores(file, true)) return;
151
+ if (!open.has(file)) for (const found of subtree(file)) listen(found);
152
+ return;
153
+ }
154
+ if (open.has(file)) open.remove(file);
155
+ };
156
+
157
+ /** The admitted directories at or under `directory`, discovered rather than assumed. */
158
+ const subtree = (directory: string): readonly string[] =>
159
+ admittedDirectories(join(root, directory), ignore).map((found) =>
160
+ found === '' ? directory : `${directory}/${found}`,
161
+ );
162
+
163
+ /** The whole set, re-derived: a directory the author just ignored gives its descriptor back. */
164
+ const reconcile = (): void => {
165
+ const admitted = new Set(admittedDirectories(root, ignore));
166
+ for (const held of open.directories()) if (!admitted.has(held)) open.remove(held);
167
+ for (const directory of admitted) if (!open.has(directory)) listen(directory);
168
+ };
169
+
170
+ for (const directory of admittedDirectories(root, ignore)) listen(directory);
171
+
172
+ return {
173
+ directories: () => open.directories(),
174
+ close(): void {
175
+ if (timer !== undefined) clearTimeout(timer);
176
+ open.closeAll();
177
+ },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * The descriptors this watcher holds, keyed by app-root-relative directory. Its own type because
183
+ * removing one means removing everything under it: a deleted directory takes its children's
184
+ * descriptors with it, and a `Map` iterated by the caller would leak every one of them.
185
+ */
186
+ class WatchDirectoryMap {
187
+ readonly #watchers = new Map<string, DirectoryWatcher>();
188
+ readonly #watch: WatchDirectory;
189
+ readonly #root: string;
190
+
191
+ constructor(watchDirectory: WatchDirectory, root: string) {
192
+ this.#watch = watchDirectory;
193
+ this.#root = root;
194
+ }
195
+
196
+ add(directory: string, listener: WatchListener): void {
197
+ if (this.#watchers.has(directory)) return;
198
+ try {
199
+ this.#watchers.set(directory, this.#watch(join(this.#root, directory), listener));
200
+ } catch {
201
+ // A directory that vanished between the walk and the registration. The parent's own
202
+ // descriptor still reports anything that reappears there.
203
+ }
204
+ }
205
+
206
+ has(directory: string): boolean {
207
+ return this.#watchers.has(directory);
208
+ }
209
+
210
+ directories(): readonly string[] {
211
+ return [...this.#watchers.keys()].sort();
212
+ }
213
+
214
+ remove(directory: string): void {
215
+ for (const held of this.#watchers.keys()) {
216
+ if (held !== directory && !held.startsWith(`${directory}/`)) continue;
217
+ this.#watchers.get(held)?.close();
218
+ this.#watchers.delete(held);
219
+ }
220
+ }
221
+
222
+ closeAll(): void {
223
+ for (const watcher of this.#watchers.values()) watcher.close();
224
+ this.#watchers.clear();
225
+ }
226
+ }
@@ -0,0 +1,75 @@
1
+ // Which paths under the app root `x dev` must not watch, and the rule is REGISTRATION rather than
2
+ // filtering: `watch(root, { recursive: true })` takes one inotify descriptor per directory before
3
+ // any filter runs, so an answer given after the event has already cost the kernel queue, a JS
4
+ // callback and a slot out of `max_user_watches`. Measured on a monorepo root: 1901 descriptors,
5
+ // 1490 of them under `.git/` and `node_modules/`, and one `git status` delivering 5 events.
6
+ //
7
+ // The ignore set is the app's own `.gitignore`, read with git's own anchoring (`gitignore.ts`),
8
+ // plus a floor of directory names an ignore file need not name. It was seven hand-listed names,
9
+ // and both halves of that were wrong: nothing read `.gitignore`, so `tsconfig.tsbuildinfo` —
10
+ // rewritten by every `bun run typecheck` — ran a full `appManifest()` plus `buildIslands()`; and
11
+ // `dist` and `coverage` were matched at ANY depth, so an app's own `/dist` or `/coverage` route
12
+ // never reloaded at all, silently.
13
+
14
+ // why: Bun exposes no path-join primitive. The same necessity `fix-path.ts` already records.
15
+ import { join } from 'node:path';
16
+ import type { IgnoreScope } from './gitignore';
17
+ import { ignoreScopes, isGitIgnored } from './gitignore';
18
+ import { pathSegments } from './path-segments';
19
+
20
+ /**
21
+ * Directory names no `.gitignore` can be relied on to carry, matched as a path SEGMENT at any
22
+ * depth. Every entry earns its line, and every one is either dotted or `node_modules` — which is
23
+ * what makes the any-depth match safe: a `site/` subtree is a URL tree, and neither a dot-directory
24
+ * nor an install is ever a route.
25
+ *
26
+ * - `.git` — git never names its own directory in an ignore file, and `git status`, `git fetch` and
27
+ * every commit rewrite index and ref files continuously. The reason this list exists.
28
+ * - `.x` — the framework's own state: PGlite's data directory (which THIS process writes
29
+ * continuously), the dev lock, the static export, `build-stats.json`.
30
+ * - `node_modules` — an install, not an edit. `x dev` cannot reload for a dependency change: the
31
+ * modules are already in this process's cache.
32
+ * - `.personal` — an app's uncommitted local state, written by scripts while the dev server runs.
33
+ * - `.claude` — agent scratch, session logs and worktrees. ai-maxxing keeps two entire copies of
34
+ * the app under `.claude/worktrees/`, so a second agent's edit rebuilt the first agent's islands.
35
+ *
36
+ * `dist` and `coverage` were here and are deliberately not: both are ordinary build output that
37
+ * every ignore file already names, and hand-listing them cost an app its own routes.
38
+ */
39
+ export const ALWAYS_IGNORED_DIRECTORIES: readonly string[] = [
40
+ '.git',
41
+ '.x',
42
+ 'node_modules',
43
+ '.personal',
44
+ '.claude',
45
+ ];
46
+
47
+ /** The ignore set as one question, so the walk and the event filter cannot disagree. */
48
+ export interface DevIgnore {
49
+ /** `path` is app-root-relative; `isDirectory` decides every trailing-slash rule git holds. */
50
+ ignores(path: string, isDirectory: boolean): boolean;
51
+ /** The `.gitignore` files this set was built from, outermost first — what `--json` can report. */
52
+ readonly scopes: readonly IgnoreScope[];
53
+ }
54
+
55
+ /**
56
+ * Read once, at boot and again on every write that names `.gitignore`. A snapshot rather than a
57
+ * live reader: the watcher rebuilds the whole set and re-walks, so a directory the author just
58
+ * ignored drops its descriptor instead of keeping one nothing will ever read an event from.
59
+ *
60
+ * Two limits, both deliberate. An ANCESTOR ignore file is read at boot and is not watched — it sits
61
+ * outside the app root, so `x dev` has no descriptor there and a restart is the way to pick up an
62
+ * edit to it. A NESTED one (`apps/web/.gitignore`) is not read at all: `ignoreScopes` walks upward
63
+ * only, and watching for one would mean a scope per directory in the tree.
64
+ */
65
+ export function devIgnore(root: string): DevIgnore {
66
+ const scopes = ignoreScopes(root);
67
+ return {
68
+ scopes,
69
+ ignores(path: string, isDirectory: boolean): boolean {
70
+ const segments = pathSegments(path);
71
+ if (segments.some((segment) => ALWAYS_IGNORED_DIRECTORIES.includes(segment))) return true;
72
+ return isGitIgnored(scopes, join(root, ...segments), isDirectory);
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,122 @@
1
+ // `x doctor`'s offline-fallback check: the path the app DECLARED, against the routes it really
2
+ // serves. Its own file because answering it needs the app's config and its route table, and
3
+ // `cmd-doctor.ts`'s job is the probe rather than the loading — the `db-backfill.ts` split, one
4
+ // diagnostic over.
5
+
6
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
7
+ import { describeRoutes } from '@ultimat3/render';
8
+ import { loadApp } from './app-load';
9
+ import { APP_CONFIG_FILE } from './app-root';
10
+ import type { Finding } from './output';
11
+ import { loadPwaArtifacts } from './pwa-artifacts';
12
+
13
+ /** A route as this check reads one: the URL it answers, and the surface that answers it. */
14
+ export interface NavigableRoute {
15
+ readonly path: string;
16
+ readonly surface: string;
17
+ }
18
+
19
+ export interface OfflineFallbackFact {
20
+ /**
21
+ * `pwa.offline.fallback` as `loadPwaArtifacts` read it, or `null` — which is both "this app
22
+ * declares no PWA" and "it declares one with no fallback". Either way no service worker is
23
+ * emitted at all (`serviceWorkerArtifacts` answers `undefined`), so the two share one remedy.
24
+ */
25
+ readonly fallback: string | null;
26
+ /**
27
+ * Every registered route, or `undefined` when the app would not load. `appEntities`' rule
28
+ * (`schema-drift.ts`) one registry over: a module that will not import leaves the registry
29
+ * short, and a short registry reads as "no route serves it" — a generator handed out for a route
30
+ * the app already has, over one file's syntax error.
31
+ */
32
+ readonly routes: readonly NavigableRoute[] | undefined;
33
+ }
34
+
35
+ /**
36
+ * The one surface an OFFLINE navigation can land on. `api/` answers a JSON document and `shared/`
37
+ * is not a URL at all — the pair `sw-artifacts.ts` excludes for the same reason — and `app/` is
38
+ * excluded for a third: `SURFACE_SPECS` allows it `stream | ssr` and nothing else, only a `static`
39
+ * route is prerendered, and the service worker precaches a rendered DOCUMENT
40
+ * (`serviceWorkerArtifacts` reads `documents.get(fallback)`). So an `app/` fallback has nothing
41
+ * to precache and the offline navigation it is supposed to answer reaches the network and fails —
42
+ * this check passing for it is the false green it exists to prevent.
43
+ *
44
+ * It accepted `app` until 2026-09, on the argument that both surfaces answer the same URL and it
45
+ * is the FIX that is opinionated. True about URLs and wrong about offline: the fix's own comment
46
+ * below already refuses `--surface app`, so the check and the remedy disagreed about one code.
47
+ *
48
+ * Residual, and NOT closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
49
+ * either. `NavigableRoute` carries no render mode, and `describeRoutes()` has one — the narrower
50
+ * check belongs with it.
51
+ */
52
+ const NAVIGABLE: ReadonlySet<string> = new Set(['site']);
53
+
54
+ /**
55
+ * A fallback `x g route <name> --surface site` can actually create: ONE path segment, which is
56
+ * what the generator turns into `apps/<app>/site/<name>/page.tsx` and therefore into `/<name>`. A
57
+ * nested or punctuated path would be slugified into a DIFFERENT url (`/support/offline` →
58
+ * `/support-offline`), so offering the command there is a fix that runs and leaves the finding
59
+ * exactly where it was.
60
+ *
61
+ * `--surface site`, and never `app`: the document that answers a lost network must render with no
62
+ * network, no session and no database, which `app/` (`ssr | stream`) cannot promise — the reason
63
+ * `x new` scaffolds it under `site/` (`wiki/Upgrading.md`) — and it is the line `@ultimat3/pwa`'s
64
+ * own `X_PWA_NO_OFFLINE_FALLBACK` hands out. Two fixes for one code are two answers.
65
+ */
66
+ const GENERATABLE = /^\/([a-z][a-z0-9-]*)$/;
67
+
68
+ const finding = (cause: string, fix: string): Finding => ({
69
+ code: 'X_PWA_NO_OFFLINE_FALLBACK',
70
+ cause,
71
+ fix,
72
+ docs: ERROR_DOCS_URL,
73
+ at: APP_CONFIG_FILE,
74
+ });
75
+
76
+ /**
77
+ * The declared fallback, judged against the route table — never against a filename.
78
+ *
79
+ * It WAS a filename: the literal `apps/web/app/offline.tsx`, which `assertRouteFilename` refuses
80
+ * outright (the directory is the URL, so a page is `page.tsx`), while `x new` scaffolds
81
+ * `apps/web/site/offline/page.tsx` and this finding's own `fix:` writes
82
+ * `apps/web/app/offline/page.tsx`. Every one of the three is a different path, so the check was
83
+ * red for every app the framework has ever produced and no invocation could clear it — the shape
84
+ * `budgets.ts` calls a false green read backwards, and the reason a diagnostic is held to being
85
+ * closable by its own fix.
86
+ */
87
+ export function offlineFallbackFinding(fact: OfflineFallbackFact): Finding | undefined {
88
+ if (fact.routes === undefined) return undefined;
89
+ const fallback = fact.fallback;
90
+ if (fallback === null) {
91
+ return finding(
92
+ 'no pwa.offline.fallback is declared, so no service worker is emitted and an offline navigation falls back to the browser error page',
93
+ `set pwa: { offline: { fallback: '/offline' } } in ${APP_CONFIG_FILE}`,
94
+ );
95
+ }
96
+ if (fact.routes.some((route) => route.path === fallback && NAVIGABLE.has(route.surface))) {
97
+ return undefined;
98
+ }
99
+ const cause = `pwa.offline.fallback is "${fallback}" and no site/ route serves it, so an offline navigation falls back to the browser error page`;
100
+ const name = GENERATABLE.exec(fallback)?.[1];
101
+ return name === undefined
102
+ ? finding(cause, `set pwa.offline.fallback in ${APP_CONFIG_FILE} to a path a route serves`)
103
+ : finding(cause, `x g route ${name} --surface site`);
104
+ }
105
+
106
+ /**
107
+ * The fact, read off a real app root. Both halves come from the framework's own answers — the
108
+ * config through `loadPwaArtifacts` (the one reader of that file) and the routes through
109
+ * `describeRoutes()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
110
+ * built from), so this check and the service worker cannot disagree about which routes exist.
111
+ */
112
+ export async function offlineFallbackProbe(root: string): Promise<OfflineFallbackFact> {
113
+ const pwa = await loadPwaArtifacts(root);
114
+ const app = await loadApp(root);
115
+ return {
116
+ fallback: pwa?.offline.fallback ?? null,
117
+ routes:
118
+ app.findings.length > 0
119
+ ? undefined
120
+ : describeRoutes().map((route) => ({ path: route.path, surface: route.surface })),
121
+ };
122
+ }