@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/src/dev-queue.ts CHANGED
@@ -78,8 +78,16 @@ export interface RunningQueue {
78
78
  * Before this, `defaultClient()` was the only composer of a replicated pair in the framework and
79
79
  * it runs only from `baseClient()` — the client an app installed NONE for. This line installs one,
80
80
  * so `DATABASE_REPLICA_URL` was read by no booted process at all.
81
+ *
82
+ * `env` is REQUIRED, and that is the repair: it defaulted to `process.env` and `startQueue` passed
83
+ * nothing, so the standby was decided from the process while the middleware that opens the
84
+ * `withReplicaReads` scope was decided from the boot's own `options.env` (`cmd-dev.ts`,
85
+ * `serve.ts`). Two sources for one question answer differently the moment a boot is handed an
86
+ * environment it did not inherit — a routed client with no scope, or a scope with no standby, and
87
+ * neither reports anything. A default here is what let the caller forget; the type is what stops
88
+ * the next one. Exported for the test that proves which environment decides.
81
89
  */
82
- function startDb(services: DevServices, env: ReplicaEnv = process.env): StartedDb {
90
+ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
83
91
  const binding = services.db;
84
92
  const client =
85
93
  binding.mode === 'embedded'
@@ -218,8 +226,14 @@ async function releaseQueue(
218
226
  export async function startQueue(
219
227
  services: DevServices,
220
228
  overrides?: RuntimeOverrides,
229
+ /**
230
+ * The boot's own environment — `x dev`'s, the container role's, the CLI command's `ctx.env`.
231
+ * `process.env` is the default for a caller that has no other answer, and it is the ONLY place
232
+ * this file reads it: see `startDb` for what two readers of one question cost.
233
+ */
234
+ env: ReplicaEnv = process.env,
221
235
  ): Promise<RunningQueue> {
222
- const { client: db, replica } = startDb(services);
236
+ const { client: db, replica } = startDb(services, env);
223
237
  try {
224
238
  // Pay the Postgres boot here, so the first request is not the slow one and a broken database
225
239
  // fails at boot rather than on some later query.
@@ -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
@@ -27,6 +27,8 @@ import {
27
27
  renderHead,
28
28
  routeDataFor,
29
29
  routeEntries,
30
+ routeFor,
31
+ routeStatusOf,
30
32
  seoRenderers,
31
33
  } from '@ultimat3/render';
32
34
  import type { IsrController } from '@ultimat3/render/server';
@@ -212,12 +214,15 @@ async function resultFor(
212
214
  // ONCE per request, before the mode is chosen. Every branch below reads this same object, so a
213
215
  // route's `load` runs exactly once however its mode splits head from body.
214
216
  const data = await routeDataFor(entry.config, request);
217
+ // The status the loader answered through `withStatus`, 200 when it said nothing. Read once,
218
+ // here, and handed to every mode: this file mints the `Response`, render owns the seam.
219
+ const status = routeStatusOf(data);
215
220
  switch (entry.config.render) {
216
221
  case 'static': {
217
222
  // Not `renderStatic`: that enumerates every prerendered path for the build. A request
218
223
  // names exactly one, and it earns the same content-hashed headers.
219
224
  const body = await documentFrom(entry, request, data, options);
220
- return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
225
+ return { status, headers: staticHeaders(contentHash(body), options.buildId), body };
221
226
  }
222
227
  case 'isr': {
223
228
  // `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — this
@@ -227,9 +232,12 @@ async function resultFor(
227
232
  // The locale is the second dimension and it is `ctx.locale`, the answer the `locale` stage
228
233
  // already negotiated for THIS request — never `currentLocale()`, which would read the same
229
234
  // value through an ambient store the key does not need.
230
- const served = await isr.serve(isrKey(url, ctx.locale), () =>
231
- documentFrom(entry, request, data, options),
232
- );
235
+ // `{ html, status }`, never the bare string: the entry stores the status beside the HTML
236
+ // and serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
237
+ const served = await isr.serve(isrKey(url, ctx.locale), async () => ({
238
+ html: await documentFrom(entry, request, data, options),
239
+ status,
240
+ }));
233
241
  return served.result;
234
242
  }
235
243
  case 'stream': {
@@ -253,13 +261,14 @@ async function resultFor(
253
261
  holes: [],
254
262
  },
255
263
  { buildId: options.buildId },
264
+ status,
256
265
  );
257
266
  }
258
267
  default:
259
268
  return renderSsr(
260
269
  { entry, params: request.params, url, ctx },
261
270
  () => documentFrom(entry, request, data, options),
262
- { buildId: options.buildId },
271
+ { buildId: options.buildId, status },
263
272
  );
264
273
  }
265
274
  }
@@ -281,15 +290,32 @@ const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
281
290
  ...(entry.config.policy === undefined ? {} : { policy: entry.config.policy.permission }),
282
291
  });
283
292
 
284
- /** One HTTP route per registered `route` primitive, in the table's own order. */
293
+ /**
294
+ * One HTTP route per registered `route` primitive, in the table's own order.
295
+ *
296
+ * The URL and the method are the table's at the time this is called; the ENTRY — the component the
297
+ * handler renders AND the `meta` the pipeline enforces — is read back from the table on every
298
+ * request. `x dev` re-registers a route module when its source changes (`app-load.ts`), and a
299
+ * handler closing over the entry it was built from kept serving the first component after every
300
+ * save — the table had moved and this closure had not. `meta` was the same defect one stage
301
+ * earlier: a snapshot taken here, so a `policy` added in a save was not enforced until a restart
302
+ * while the page behind it was already the new one — the pipeline's `auth` and `authz` stages read
303
+ * `route.meta` per request, and this getter is what makes that read the table's. The path cannot
304
+ * move under a reload (the table derives it from the file, and the file is the reload's key), so
305
+ * the entry captured here is only the fallback for a table cleared under a running server, which
306
+ * only a test does — and then guard and page fall back together.
307
+ */
285
308
  export function appRoutes(options: DevRenderOptions): readonly Route[] {
286
309
  const isr = options.isr ?? createIsrController({ buildId: options.buildId });
287
- return routeEntries().map((entry) => ({
310
+ return routeEntries().map((registered) => ({
288
311
  method: 'GET' as const,
289
- path: entry.path,
290
- meta: metaOf(entry),
312
+ path: registered.path,
313
+ get meta(): HttpRouteMeta {
314
+ return metaOf(routeFor(registered.path) ?? registered);
315
+ },
291
316
  // `ctx.params` is the router's own match — the CLI never re-parses a path it did not match.
292
317
  handler: async (request, ctx): Promise<Response> => {
318
+ const entry = routeFor(registered.path) ?? registered;
293
319
  const data: DevRouteData = { url: request.url.href, params: ctx.params };
294
320
  return responseOf(await resultFor(entry, data, options, isr, asCtx(ctx)));
295
321
  },
@@ -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,7 +15,7 @@ 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
21
  import { DEV_BINDING } from './web-binding';
@@ -53,7 +53,11 @@ class SyncPortInUseError extends UltimateError {
53
53
  super({
54
54
  code: 'X_PORT_IN_USE',
55
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`,
56
- 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`,
57
61
  meta: { port: input.port, webPort: input.webPort },
58
62
  });
59
63
  }
@@ -183,7 +187,11 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
183
187
  // so the one socket that streams live database patches was the one socket on every
184
188
  // interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
185
189
  const binding = options.http ?? DEV_BINDING;
186
- const listener = listenSyncNode(node, { port, hostname: binding.hostname });
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 });
187
195
  return {
188
196
  url: listener.url,
189
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
+ }
package/src/dev-watch.ts CHANGED
@@ -1,53 +1,75 @@
1
- // Which writes under the app root are a source change, and which are noise. Split out of
2
- // `cmd-dev.ts` because it is a rule with cases rather than four lines of glue, and because the
3
- // answer needs a test of its own: a watcher that reloads on the wrong write is invisible — the
4
- // dev server stays correct and merely does the most expensive thing it can do, repeatedly.
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.
5
6
  //
6
- // What it was: `filename.includes('.x/') || filename.includes('node_modules')`. Measured against
7
- // ai-maxxing, whose checkout carries `.git/`, `.personal/`, `.claude/worktrees/` (two FULL copies
8
- // of the app) and `coverage/`, every write under any of them ran a whole `appManifest()` plus a
9
- // `buildIslands()` over ten islands. `git status`, an agent's scratch file and a coverage run each
10
- // cost a full rebuild, and each rebuild re-minted every island chunk.
11
- //
12
- // `includes` was also the wrong operator, not just the wrong list: a directory legitimately named
13
- // `my-node_modules-notes/` was excluded from the dev loop for a substring, and `notes/.xyz/` for
14
- // another. The match is on a PATH SEGMENT, so only the directory itself is ever ignored.
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';
15
19
 
16
20
  /**
17
- * Directories whose writes are never an app source change.
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.
18
25
  *
19
- * Every entry earns its line and none is a guess:
20
- * - `.x` — the framework's own state directory: PGlite's data, the dev lock, the static export,
21
- * `build-stats.json`. `x build` writes here, which made a build trigger reloads of the process
22
- * that was running it.
23
- * - `node_modules` — an install, not an edit. `x dev` does not reload for a dependency change
24
- * because it cannot: the modules are already in this process's cache.
25
- * - `.git` — the reason this list exists. `git status`, `git fetch` and every commit rewrite index
26
- * and ref files continuously, and none of them is a source edit; a checkout of a branch IS one,
27
- * but it also writes the source files themselves, which this list does not touch.
28
- * - `.personal` — an app's uncommitted local state (ai-maxxing's fleet file, its inventory, its
29
- * credentials). Written by scripts while the dev server runs.
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.
30
33
  * - `.claude` — agent scratch, session logs and worktrees. ai-maxxing keeps two entire copies of
31
34
  * the app under `.claude/worktrees/`, so a second agent's edit rebuilt the first agent's islands.
32
- * - `dist`, `coverage` — build and test output. Both are written by commands an author runs
33
- * BESIDE `x dev`, which is exactly when a spurious rebuild costs the most.
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.
34
38
  */
35
- export const IGNORED_DIRECTORIES: readonly string[] = [
39
+ export const ALWAYS_IGNORED_DIRECTORIES: readonly string[] = [
40
+ '.git',
36
41
  '.x',
37
42
  'node_modules',
38
- '.git',
39
43
  '.personal',
40
44
  '.claude',
41
- 'dist',
42
- 'coverage',
43
45
  ];
44
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
+
45
55
  /**
46
- * A path segment, never a substring. `filename` arrives from `node:fs`'s watcher root-relative and
47
- * with the platform's separator, so both are normalised before the split — a rule that reads
48
- * `foo/node_modules/bar` and not `foo\\node_modules\\bar` is a rule that does not exist on Windows.
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.
49
64
  */
50
- export function isIgnoredPath(filename: string): boolean {
51
- const segments = filename.replaceAll('\\', '/').split('/');
52
- return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
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
+ };
53
75
  }