@ultimat3/cli 22.6.1 → 22.7.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.6.1",
3
+ "version": "22.7.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,33 +38,33 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@babel/core": "^8.0.1",
41
- "@ultimat3/action": "22.6.1",
42
- "@ultimat3/admin": "22.6.1",
43
- "@ultimat3/ai": "22.6.1",
44
- "@ultimat3/auth": "22.6.1",
45
- "@ultimat3/cache": "22.6.1",
46
- "@ultimat3/core": "22.6.1",
47
- "@ultimat3/db": "22.6.1",
48
- "@ultimat3/entity": "22.6.1",
49
- "@ultimat3/flags": "22.6.1",
50
- "@ultimat3/http": "22.6.1",
51
- "@ultimat3/i18n": "22.6.1",
52
- "@ultimat3/jobs": "22.6.1",
53
- "@ultimat3/mail": "22.6.1",
54
- "@ultimat3/manifest": "22.6.1",
55
- "@ultimat3/mcp": "22.6.1",
56
- "@ultimat3/money": "22.6.1",
57
- "@ultimat3/notify": "22.6.1",
58
- "@ultimat3/policy": "22.6.1",
59
- "@ultimat3/pwa": "22.6.1",
60
- "@ultimat3/query": "22.6.1",
61
- "@ultimat3/realtime": "22.6.1",
62
- "@ultimat3/render": "22.6.1",
63
- "@ultimat3/schema": "22.6.1",
64
- "@ultimat3/seo": "22.6.1",
65
- "@ultimat3/storage": "22.6.1",
66
- "@ultimat3/testing": "22.6.1",
67
- "@ultimat3/time": "22.6.1",
41
+ "@ultimat3/action": "22.7.0",
42
+ "@ultimat3/admin": "22.7.0",
43
+ "@ultimat3/ai": "22.7.0",
44
+ "@ultimat3/auth": "22.7.0",
45
+ "@ultimat3/cache": "22.7.0",
46
+ "@ultimat3/core": "22.7.0",
47
+ "@ultimat3/db": "22.7.0",
48
+ "@ultimat3/entity": "22.7.0",
49
+ "@ultimat3/flags": "22.7.0",
50
+ "@ultimat3/http": "22.7.0",
51
+ "@ultimat3/i18n": "22.7.0",
52
+ "@ultimat3/jobs": "22.7.0",
53
+ "@ultimat3/mail": "22.7.0",
54
+ "@ultimat3/manifest": "22.7.0",
55
+ "@ultimat3/mcp": "22.7.0",
56
+ "@ultimat3/money": "22.7.0",
57
+ "@ultimat3/notify": "22.7.0",
58
+ "@ultimat3/policy": "22.7.0",
59
+ "@ultimat3/pwa": "22.7.0",
60
+ "@ultimat3/query": "22.7.0",
61
+ "@ultimat3/realtime": "22.7.0",
62
+ "@ultimat3/render": "22.7.0",
63
+ "@ultimat3/schema": "22.7.0",
64
+ "@ultimat3/seo": "22.7.0",
65
+ "@ultimat3/storage": "22.7.0",
66
+ "@ultimat3/testing": "22.7.0",
67
+ "@ultimat3/time": "22.7.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/app-load.ts CHANGED
@@ -6,12 +6,12 @@
6
6
  // Bun ships no `Bun.*` path API: `relative`/`sep` turn an absolute scan hit into the app-root-
7
7
  // relative POSIX path every finding and every manifest fact is keyed by.
8
8
  import { join, relative, resolve, sep } from 'node:path';
9
- import { listActions, registerActions } from '@ultimat3/action';
10
- import { describeEntities } from '@ultimat3/entity';
9
+ import { isAction, isMutator, listActions, registerActions } from '@ultimat3/action';
10
+ import { describeEntities, registeredEntities } from '@ultimat3/entity';
11
11
  import { localeConfig } from '@ultimat3/i18n';
12
- import { registeredJobs, registeredTasks } from '@ultimat3/jobs';
12
+ import { isJobHandle, isTaskHandle, registeredJobs, registeredTasks } from '@ultimat3/jobs';
13
13
  import type { ErrorCodeFact } from '@ultimat3/manifest';
14
- import { listQueries, registerQueries } from '@ultimat3/query';
14
+ import { isQuery, listQueries, registerQueries } from '@ultimat3/query';
15
15
  import type { RouteConfig } from '@ultimat3/render';
16
16
  import {
17
17
  isRouteConfig,
@@ -28,6 +28,13 @@ import {
28
28
  // accident one refactor away from compiling every app's `.tsx` to `React.createElement`. The named
29
29
  // import is a value import, so the side effect holds without the bare line it replaced.
30
30
  import { setStylesheetRoot } from '@ultimat3/render/server';
31
+ import {
32
+ evictChanged,
33
+ pinModule,
34
+ resetReloadGraph,
35
+ trackModule,
36
+ trackStylesheets,
37
+ } from './app-reload-graph';
31
38
  import { API_INDEX, APP_CONFIG_FILE } from './app-root';
32
39
  import { collectDeclaredCodes } from './error-contract';
33
40
  import type { Finding } from './output';
@@ -89,30 +96,29 @@ export interface LoadedApp {
89
96
 
90
97
  // A module is imported and registered once per PROCESS: `import()` caches, and a registry rejects
91
98
  // a second registration of a name. So a rescan refreshes the facts DERIVED from the registries —
92
- // the manifest and its build id — and, for exactly one kind of module, the primitive itself. A
93
- // ROUTE module whose source changed is imported again under `?x-reload=<hash>`, the one cache key
94
- // Bun honours, and `registerRoute` replaces the entry for the same file; its own imports resolve
95
- // to the modules already cached, which is what makes it safe. An action, a query or an entity
96
- // stays registered once: its exports are held by every module that imported it, a second instance
97
- // would be a duplicate name in its registry, and no re-import can rebind the importers — those
98
- // edits need a restart. Until 2026-09-07 the route module took the same rule, so a save re-bundled
99
- // the island (`buildIslands` reads the disk) and kept the FIRST page component — a new island
100
- // rendering under an old page's props, which is the mixed generation `x dev` served.
99
+ // the manifest and its build id — and, for exactly one kind of module, the registration itself. A
100
+ // rescan first evicts every changed module, and everything that imports it, from Bun's registry
101
+ // (`app-reload-graph.ts`), so the loop's own `import()` evaluates the new chain; a ROUTE module
102
+ // that comes back as a different instance has its entry replaced. A module that defines a
103
+ // primitive (an action, a query, an entity, a job, a task) is pinned instead — its exports are held
104
+ // by every importer and a second instance would be a duplicate name in its registry — so those
105
+ // edits still need a restart. Until 2026-09-27 only the route module itself was re-imported, under
106
+ // `?x-reload=<hash>`, and every component it imports stayed the cached one: `x dev` logged
107
+ // "reloaded" and served the old component.
101
108
  const registered = new Set<string>();
102
109
  // A registration failure is sticky: the file is never retried, so the finding is replayed.
103
110
  const failures = new Map<string, Finding>();
104
- // Route modules only: the hash of the source each one registered from, which is what a rescan
105
- // compares the disk against. A save that leaves the bytes alone re-imports nothing.
106
- const routeSources = new Map<string, bigint>();
107
-
108
- /** The query a re-imported route module carries. `module-loader.ts`'s filter admits it. */
109
- const RELOAD_QUERY = 'x-reload';
111
+ // Route modules only: the module instance each one registered from. A rescan that imports the same
112
+ // instance registers nothing; a different one (the file or something it imports was evicted) is
113
+ // the page the author saved.
114
+ const routeModules = new Map<string, Record<string, unknown>>();
110
115
 
111
116
  /** Test seam, and what `x dev` would call if it ever restarted the registries in-process. */
112
117
  export function resetAppLoad(): void {
113
118
  registered.clear();
114
119
  failures.clear();
115
- routeSources.clear();
120
+ routeModules.clear();
121
+ resetReloadGraph();
116
122
  }
117
123
 
118
124
  /**
@@ -156,6 +162,9 @@ export async function loadApp(root: string): Promise<LoadedApp> {
156
162
  setAssetResolver((path) => assets.resolve(path).url);
157
163
  const files: string[] = [];
158
164
  const findings: Finding[] = [];
165
+ // A rescan: whatever changed on disk since the last one, and everything importing it, leaves
166
+ // Bun's module registry before the loop below imports it again.
167
+ if (registered.size > 0) await evictChanged(root);
159
168
 
160
169
  for (const absolute of await appModulePaths(root)) {
161
170
  // A SEGMENT, never a substring: an app checked out under
@@ -168,13 +177,16 @@ export async function loadApp(root: string): Promise<LoadedApp> {
168
177
  if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
169
178
  continue;
170
179
  }
171
- // The source is read BEFORE the import, and only on the file's first pass — a rescan of a
172
- // registered module reads nothing here. A route entry is bound to the bytes the module was
173
- // evaluated from, and a read AFTER the import cannot know which bytes those were: a save
174
- // landing between the two bound V1's component to V2's hash, so the next scan saw nothing to
175
- // do and served V1 until the save after. Read first, the worst case is one re-import the
176
- // next tick, of a file that did change.
177
- const snapshot = registered.has(absolute) ? undefined : await Bun.file(absolute).text();
180
+ // The source is read BEFORE the import, on the file's first pass and for every route module —
181
+ // a rescan of any other registered module reads nothing here. What is recorded is bound to the
182
+ // bytes the module was evaluated from, and a read AFTER the import cannot know which bytes
183
+ // those were: a save landing between the two bound V1's component to V2's hash, so the next
184
+ // scan saw nothing to do and served V1 until the save after. Read first, the worst case is one
185
+ // re-import the next tick, of a file that did change.
186
+ const first = !registered.has(absolute) && !failures.has(absolute);
187
+ const snapshot =
188
+ first || routeModules.has(absolute) ? await Bun.file(absolute).text() : undefined;
189
+ if (first && snapshot !== undefined) await trackModule(absolute, snapshot, root);
178
190
  let module: Record<string, unknown>;
179
191
  try {
180
192
  module = (await import(absolute)) as Record<string, unknown>;
@@ -188,6 +200,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
188
200
  }
189
201
 
190
202
  files.sort();
203
+ await trackStylesheets(root);
191
204
  // An app that imported NOTHING and reported nothing is a registry every later step reads as
192
205
  // empty-and-fine. With an `app.config.ts` beside it, that is never what the author meant.
193
206
  if (
@@ -226,8 +239,8 @@ const emptyAppFinding = (root: string): Finding => ({
226
239
 
227
240
  /**
228
241
  * Registers a module once; every later call replays whatever the first one reported — except for
229
- * a route module, which a later call re-registers from disk when its source has changed.
230
- * `snapshot` is the source read before the module's first import, and absent on every later call.
242
+ * a route module, which a later call re-registers when the import handed back a new instance.
243
+ * `snapshot` is the source read before this import: always for a first pass and a route module.
231
244
  */
232
245
  async function register(
233
246
  absolute: string,
@@ -237,16 +250,18 @@ async function register(
237
250
  ): Promise<Finding | undefined> {
238
251
  const previous = failures.get(absolute);
239
252
  if (previous !== undefined) return previous;
240
- if (snapshot === undefined) return reloadRoute(absolute, file);
253
+ if (registered.has(absolute)) return reloadRoute(absolute, file, module, snapshot);
241
254
  registered.add(absolute);
242
255
  try {
243
256
  const config = module['config'];
244
257
  const route = isRouteConfig(config) ? config : undefined;
245
- if (route !== undefined) registerRouteModule(file, module, route, snapshot);
258
+ if (route !== undefined && snapshot !== undefined) {
259
+ registerRouteModule(file, module, route, snapshot);
260
+ routeModules.set(absolute, module);
261
+ }
262
+ if (definesPrimitive(module, route !== undefined) || file === API_INDEX) pinModule(absolute);
246
263
  registerActions(module);
247
264
  registerQueries(module);
248
- // Only a route module is ever re-imported, so only a route module's hash is worth keeping.
249
- if (route !== undefined) routeSources.set(absolute, Bun.hash.wyhash(snapshot));
250
265
  return undefined;
251
266
  } catch (error) {
252
267
  const finding: Finding = { ...findingFrom(error), at: file };
@@ -255,6 +270,31 @@ async function register(
255
270
  }
256
271
  }
257
272
 
273
+ /**
274
+ * A module whose instance must be the one every importer and every registry holds. A route module
275
+ * may export an action beside its page — it has always been re-imported, and its actions stayed
276
+ * registered from the first instance — so only an entity, a job or a task pins a route.
277
+ */
278
+ function definesPrimitive(module: Record<string, unknown>, route: boolean): boolean {
279
+ // The registry holds an entry per entity; the value a module exports is the entry's `core`.
280
+ const entities = new Set<unknown>(registeredEntities().flatMap((entry) => [entry, entry.core]));
281
+ const defines = (value: unknown): boolean =>
282
+ entities.has(value) ||
283
+ isJobHandle(value) ||
284
+ isTaskHandle(value) ||
285
+ (!route && (isAction(value) || isQuery(value) || isMutator(value)));
286
+ return Object.values(module).some((value) => {
287
+ // A brand check reads a property, and an export may be a proxy whose every read throws until
288
+ // the env is set — examples/dummy's typed client throws X_ENV_MISSING without APP_URL. A value
289
+ // that cannot be asked is not a primitive: every primitive is a plain branded object.
290
+ try {
291
+ return defines(value);
292
+ } catch {
293
+ return false;
294
+ }
295
+ });
296
+ }
297
+
258
298
  /**
259
299
  * The route half of a registration, and the whole of a re-registration. `source` is the text the
260
300
  * module was imported from — read by the caller BEFORE the import, never here after it — and the
@@ -283,25 +323,26 @@ function registerRouteModule(
283
323
  }
284
324
 
285
325
  /**
286
- * A registered route module, on a rescan: imported again if the file no longer hashes to what it
287
- * registered from, and left alone otherwise. A save that will not import is a finding at the file
288
- * and NOT a sticky one — the next save is tried again — and the entry it would have replaced stays
289
- * registered, so the last page that did import is the one served meanwhile.
326
+ * A registered route module, on a rescan: re-registered when the import handed back a different
327
+ * instance — the file or something it imports was evicted — and left alone otherwise. A save that
328
+ * will not import never reaches here (the loop reports it, not sticky), so the last page that did
329
+ * import is the one served meanwhile.
290
330
  */
291
- async function reloadRoute(absolute: string, file: string): Promise<Finding | undefined> {
292
- const registeredFrom = routeSources.get(absolute);
293
- if (registeredFrom === undefined) return undefined;
294
- const source = await Bun.file(absolute).text();
295
- const hash = Bun.hash.wyhash(source);
296
- if (hash === registeredFrom) return undefined;
331
+ function reloadRoute(
332
+ absolute: string,
333
+ file: string,
334
+ module: Record<string, unknown>,
335
+ source: string | undefined,
336
+ ): Finding | undefined {
337
+ const held = routeModules.get(absolute);
338
+ if (held === undefined || held === module || source === undefined) return undefined;
297
339
  try {
298
- const module = (await import(`${absolute}?${RELOAD_QUERY}=${hash}`)) as Record<string, unknown>;
299
340
  const config = module['config'];
300
341
  // A file that stopped being a route is not un-registered — the table has no verb for it, and a
301
- // deleted file needs a restart either way. Its hash is recorded so the same save is not
302
- // re-imported on every later tick.
342
+ // deleted file needs a restart either way. Its instance is recorded so the same save is not
343
+ // re-registered on every later tick.
303
344
  if (isRouteConfig(config)) registerRouteModule(file, module, config, source);
304
- routeSources.set(absolute, hash);
345
+ routeModules.set(absolute, module);
305
346
  return undefined;
306
347
  } catch (error) {
307
348
  return { ...findingFrom(error), at: file };
@@ -0,0 +1,228 @@
1
+ // Which modules a rescan must evaluate again, and making Bun do it. `import()` caches by path for
2
+ // the life of the process, so a re-import sees the edited file only once that path has left Bun's
3
+ // module registry (`delete require.cache[path]`, which Bun 1.4 honours for ES modules too). Until
4
+ // 2026-09-27 `x dev` re-imported only the edited ROUTE module, under `?x-reload=<hash>`, and every
5
+ // module that route imports resolved to the instance already cached: a save to a component, its
6
+ // `.module.scss` or a helper logged "reloaded" and rendered the old code until a restart
7
+ // (notificado.co: the panel's balance card and the home page, observed twice on one day).
8
+ //
9
+ // The rule is a reverse-dependency closure. A rescan hashes every file this graph knows; each
10
+ // changed file, and every module that imports it transitively, leaves the registry, and the scan's
11
+ // own `import()` of each app module then evaluates the new chain. Evicting the entry — rather than
12
+ // a query per save — is also what keeps the process flat: the replaced instance has no referrer
13
+ // once the route table holds the new page, and is collected. A query key is a new registry entry
14
+ // per save, never freed.
15
+ //
16
+ // A module that DEFINES a primitive is PINNED and never leaves the registry: its exports are held
17
+ // by every importer and by a registry that refuses a second definition of one name (an entity is
18
+ // `X_ENTITY_DUPLICATE`; a second action instance is a handler nobody routes to). The closure stops
19
+ // at a pinned module, and an edit to one still needs a restart — exactly what it needed before.
20
+
21
+ // why: Bun ships no path API; import specifiers resolve against the importing file's directory.
22
+ import { dirname, resolve } from 'node:path';
23
+ import { registeredStylesheets } from '@ultimat3/render/server';
24
+ import { hasPathSegment } from './path-segments';
25
+
26
+ type Kind = 'script' | 'style' | 'data';
27
+
28
+ interface Node {
29
+ readonly kind: Kind;
30
+ /** `null`: the file is gone. A deleted file is a change too — its importers must fail loudly. */
31
+ hash: bigint | null;
32
+ /** The in-app files this one imports (a sheet: the partials its compilation read). */
33
+ deps: Set<string>;
34
+ }
35
+
36
+ const nodes = new Map<string, Node>();
37
+ /** Reverse edges: file → the files that import it. */
38
+ const importers = new Map<string, Set<string>>();
39
+ const pinned = new Set<string>();
40
+ /**
41
+ * Edges cost a transpile scan and a resolve per import (~300 ms over notificado.co's 1200 modules),
42
+ * so only the one process that rescans pays it: `x dev` turns this on before its first scan. Off,
43
+ * a rescan still re-evaluates each changed module itself — a route saved in place reloads — but
44
+ * nothing it is imported by.
45
+ */
46
+ let tracking = false;
47
+
48
+ export function enableReloadTracking(on: boolean): void {
49
+ tracking = on;
50
+ }
51
+
52
+ /** Test seam, and `resetAppLoad`'s: forget every file, edge and pin. */
53
+ export function resetReloadGraph(): void {
54
+ nodes.clear();
55
+ importers.clear();
56
+ pinned.clear();
57
+ }
58
+
59
+ /** Marks a module that defines a primitive: never evicted, and the closure stops at it. */
60
+ export function pinModule(absolute: string): void {
61
+ pinned.add(absolute);
62
+ }
63
+
64
+ const SCRIPT = /\.(?:[cm]?[jt]sx?)$/;
65
+ const STYLE = /\.(?:s[ac]ss|css)$/;
66
+
67
+ const kindOf = (path: string): Kind =>
68
+ SCRIPT.test(path) ? 'script' : STYLE.test(path) ? 'style' : 'data';
69
+
70
+ const transpilers = {
71
+ ts: new Bun.Transpiler({ loader: 'ts' }),
72
+ tsx: new Bun.Transpiler({ loader: 'tsx' }),
73
+ };
74
+
75
+ const readText = async (path: string): Promise<string | null> => {
76
+ try {
77
+ return await Bun.file(path).text();
78
+ } catch {
79
+ return null;
80
+ }
81
+ };
82
+
83
+ const hashOf = (text: string | null): bigint | null =>
84
+ text === null ? null : BigInt(Bun.hash.wyhash(text));
85
+
86
+ /** The in-app files `source` imports — a package under `node_modules` is not the app's to reload. */
87
+ function importsOf(path: string, source: string, root: string): string[] {
88
+ let specifiers: readonly { readonly path: string }[];
89
+ try {
90
+ const transpiler = path.endsWith('x') ? transpilers.tsx : transpilers.ts;
91
+ specifiers = transpiler.scanImports(source);
92
+ } catch {
93
+ // A file that will not parse imports nothing yet; its own hash still changes on the fix.
94
+ return [];
95
+ }
96
+ const found: string[] = [];
97
+ for (const { path: specifier } of specifiers) {
98
+ let target: string;
99
+ try {
100
+ target = Bun.resolveSync(specifier, dirname(path));
101
+ } catch {
102
+ continue;
103
+ }
104
+ if (target.startsWith(`${root}/`) && !hasPathSegment(target, 'node_modules')) {
105
+ found.push(target);
106
+ }
107
+ }
108
+ return found;
109
+ }
110
+
111
+ function link(from: string, to: string): void {
112
+ let set = importers.get(to);
113
+ if (set === undefined) {
114
+ set = new Set();
115
+ importers.set(to, set);
116
+ }
117
+ set.add(from);
118
+ }
119
+
120
+ function unlink(from: string, node: Node): void {
121
+ for (const dep of node.deps) importers.get(dep)?.delete(from);
122
+ node.deps = new Set();
123
+ }
124
+
125
+ async function edges(path: string, node: Node, source: string, root: string): Promise<void> {
126
+ unlink(path, node);
127
+ for (const dep of importsOf(path, source, root)) {
128
+ node.deps.add(dep);
129
+ link(path, dep);
130
+ if (!nodes.has(dep)) await track(dep, await readText(dep), root);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Records `absolute` as it reads NOW — `source` is the text the caller is about to import, read
136
+ * before the import, so a save landing mid-import is a change on the next rescan rather than a
137
+ * hash bound to bytes that were never evaluated.
138
+ */
139
+ export async function trackModule(absolute: string, source: string, root: string): Promise<void> {
140
+ await track(absolute, source, resolve(root));
141
+ }
142
+
143
+ async function track(path: string, source: string | null, root: string): Promise<void> {
144
+ const node: Node = nodes.get(path) ?? { kind: kindOf(path), hash: null, deps: new Set() };
145
+ nodes.set(path, node);
146
+ node.hash = hashOf(source);
147
+ if (tracking && node.kind === 'script' && source !== null) await edges(path, node, source, root);
148
+ }
149
+
150
+ /**
151
+ * After a scan: every sheet the loader compiled, and an edge from each partial it read to it. A
152
+ * partial is not a module — nothing imports it — so this is the only place its edits are seen.
153
+ */
154
+ export async function trackStylesheets(root: string): Promise<void> {
155
+ if (!tracking) return;
156
+ const base = resolve(root);
157
+ const inApp = (path: string): boolean =>
158
+ path.startsWith(`${base}/`) && !hasPathSegment(path, 'node_modules');
159
+ for (const sheet of registeredStylesheets()) {
160
+ if (!inApp(sheet.file)) continue;
161
+ if (!nodes.has(sheet.file)) await track(sheet.file, await readText(sheet.file), base);
162
+ const node = nodes.get(sheet.file);
163
+ if (node === undefined) continue;
164
+ for (const dep of sheet.dependencies) {
165
+ if (!inApp(dep)) continue;
166
+ node.deps.add(dep);
167
+ link(sheet.file, dep);
168
+ if (!nodes.has(dep)) await track(dep, await readText(dep), base);
169
+ }
170
+ }
171
+ }
172
+
173
+ /** Every file that changed since it was recorded, re-recorded as it reads now. */
174
+ async function changedFiles(root: string): Promise<string[]> {
175
+ const known = [...nodes.keys()];
176
+ const texts = await Promise.all(known.map(readText));
177
+ const changed: string[] = [];
178
+ for (const [index, path] of known.entries()) {
179
+ const node = nodes.get(path);
180
+ const text = texts[index] ?? null;
181
+ if (node === undefined || hashOf(text) === node.hash) continue;
182
+ changed.push(path);
183
+ await track(path, text, root);
184
+ }
185
+ return changed;
186
+ }
187
+
188
+ /** `changed` and everything above it, stopping at — and excluding — every pinned module. */
189
+ function closure(changed: readonly string[]): Set<string> {
190
+ const dirty = new Set<string>();
191
+ const stack = changed.filter((path) => !pinned.has(path));
192
+ while (stack.length > 0) {
193
+ const path = stack.pop();
194
+ if (path === undefined || dirty.has(path)) continue;
195
+ dirty.add(path);
196
+ for (const importer of importers.get(path) ?? []) {
197
+ if (!pinned.has(importer)) stack.push(importer);
198
+ }
199
+ }
200
+ return dirty;
201
+ }
202
+
203
+ /**
204
+ * The start of a rescan: evicts every changed module and its importers from Bun's registry, so
205
+ * the scan's `import()` evaluates them again. A sheet is re-imported here as well, because a
206
+ * partial's edit changes no importer's text — the loader's `onLoad` is what re-registers its CSS.
207
+ * Returns the evicted paths.
208
+ */
209
+ export async function evictChanged(root: string): Promise<ReadonlySet<string>> {
210
+ const dirty = closure(await changedFiles(resolve(root)));
211
+ const reloadSheets: string[] = [];
212
+ for (const path of dirty) {
213
+ // A sheet the process never loaded (a partial) has nothing for its own import to refresh.
214
+ if (nodes.get(path)?.kind === 'style' && path in require.cache) reloadSheets.push(path);
215
+ // Unconditionally: a module whose import FAILED is absent from `require.cache` and still held
216
+ // by Bun's loader — measured on 1.4.0, the fixed file kept throwing the old parse error until
217
+ // this delete, which clears that entry too.
218
+ delete require.cache[path];
219
+ }
220
+ for (const sheet of reloadSheets) {
221
+ try {
222
+ await import(sheet);
223
+ } catch {
224
+ // Reported where it matters: the module that imports the sheet fails its import, at its file.
225
+ }
226
+ }
227
+ return dirty;
228
+ }
@@ -6,11 +6,18 @@ import type { CommandSpec } from './parse';
6
6
  export const buildSpec: CommandSpec = {
7
7
  name: 'build',
8
8
  summary: 'build a container image, a single binary, or a prerendered static site',
9
- usage: 'x build --target docker|binary|static [--tag name] [--out path] [--json]',
9
+ usage:
10
+ 'x build --target docker|binary|static [--tag name] [--out path] [--no-preflight] [--json]',
10
11
  requiresApp: true,
11
12
  flags: [
12
13
  { name: 'target', type: 'string', summary: 'docker | binary | static', default: 'docker' },
13
14
  { name: 'tag', type: 'string', summary: 'image tag (docker target)' },
14
15
  { name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
16
+ {
17
+ name: 'preflight',
18
+ type: 'boolean',
19
+ summary:
20
+ '--no-preflight skips the six static gate steps (typecheck, lint, boundaries, filesize, package-shape, errors) — only when `x verify` runs right after, as in `x build --no-preflight && x verify`',
21
+ },
15
22
  ],
16
23
  };
package/src/cmd-build.ts CHANGED
@@ -184,6 +184,16 @@ export function buildResult(input: {
184
184
  };
185
185
  }
186
186
 
187
+ /** The static steps `x build` runs before it builds, and `--no-preflight` leaves to the gate. */
188
+ export const PREFLIGHT_STEPS: readonly string[] = [
189
+ 'typecheck',
190
+ 'lint',
191
+ 'boundaries',
192
+ 'filesize',
193
+ 'package-shape',
194
+ 'errors',
195
+ ];
196
+
187
197
  export const buildCommand: CliCommand = {
188
198
  spec: buildSpec,
189
199
  async run(ctx: CommandContext): Promise<CommandResult> {
@@ -194,14 +204,19 @@ export const buildCommand: CliCommand = {
194
204
  // an agent spends on the wrong question.
195
205
  requireEntry(root, target);
196
206
 
197
- // Run static verify steps before building.
198
- const staticSteps = ['typecheck', 'lint', 'boundaries', 'filesize', 'package-shape', 'errors'];
199
- const verifySteps = (await import('./cmd-verify')).VERIFY_STEPS.filter((step) =>
200
- staticSteps.includes(step.name),
201
- );
202
- const verifyResult = await runVerify(verifySteps, { root, runner: ctx.runner, env: ctx.env });
203
- if (!verifyResult.ok) {
204
- return preflightResult(verifyResult);
207
+ // Run static verify steps before building — unless the caller is a gate that runs the same
208
+ // six steps itself right after (`bin/check`: `x build --no-preflight && x verify`), where the
209
+ // preflight was the same ~17 s of typecheck/lint/scans paid twice per run. The default stays
210
+ // safe: a bare `x build` never ships an artifact from a tree that does not typecheck.
211
+ const preflight = ctx.args.flags.get('preflight') !== false;
212
+ if (preflight) {
213
+ const verifySteps = (await import('./cmd-verify')).VERIFY_STEPS.filter((step) =>
214
+ PREFLIGHT_STEPS.includes(step.name),
215
+ );
216
+ const verifyResult = await runVerify(verifySteps, { root, runner: ctx.runner, env: ctx.env });
217
+ if (!verifyResult.ok) {
218
+ return preflightResult(verifyResult);
219
+ }
205
220
  }
206
221
 
207
222
  // A relative `--out` is a path the caller typed from where they stand, so it resolves against
@@ -129,6 +129,32 @@ export const config = defineRoute({
129
129
  export function Page() {
130
130
  return <p>generation one</p>;
131
131
  }
132
+ `,
133
+
134
+ // An ISR page whose body is a COMPONENT in another file — the shape every real page has. What the
135
+ // component-reload test rewrites: the page module's own bytes never change, so only evicting what
136
+ // imports the component re-evaluates it, and only clearing the ISR store stops the first render
137
+ // from answering for the rest of the process (notificado.co, 2026-09-27).
138
+ 'apps/web/site/news/headline.tsx': `export function Headline() {
139
+ return <h1>headline one</h1>;
140
+ }
141
+ `,
142
+
143
+ 'apps/web/site/news/page.tsx': `import { defineRoute } from '@ultimat3/render';
144
+ import { Headline } from './headline';
145
+
146
+ export const config = defineRoute({
147
+ render: 'isr',
148
+ revalidate: { ttl: '1h' },
149
+ offline: 'runtime',
150
+ hydrate: 'never',
151
+ budget: { js: '0kb' },
152
+ meta: () => ({ title: 'News', description: 'A page whose component is edited while x dev runs' }),
153
+ });
154
+
155
+ export function Page() {
156
+ return <main><Headline /></main>;
157
+ }
132
158
  `,
133
159
 
134
160
  // A stylesheet the page imports, because that import is what registers it — and the document's