@ultimat3/cli 22.6.2 → 22.7.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.
- package/package.json +28 -28
- package/src/app-load.ts +88 -47
- package/src/app-reload-graph.ts +228 -0
- package/src/cmd-build-spec.ts +8 -1
- package/src/cmd-build.ts +23 -8
- package/src/cmd-dev-fixture.ts +26 -0
- package/src/cmd-dev.ts +10 -0
- package/src/cmd-test-spec.ts +9 -10
- package/src/cmd-test.ts +25 -2
- package/src/cmd-verify-spec.ts +37 -13
- package/src/cmd-verify.ts +81 -5
- package/src/dev-route-table.ts +7 -0
- package/src/error-codes.ts +8 -0
- package/src/mcp-errors.ts +5 -0
- package/src/messages.ts +1 -0
- package/src/output.ts +26 -0
- package/src/templates/scaffold-docs.ts +3 -1
- package/src/test-batches.ts +35 -4
- package/src/test-shards.ts +74 -10
- package/src/test-slots.ts +168 -0
- package/src/test-workers.ts +169 -80
- package/src/verify-errors.ts +49 -0
- package/src/verify-floor.ts +28 -0
- package/src/verify-merge.ts +235 -0
- package/src/verify-run.ts +16 -9
- package/src/verify-shard.ts +145 -0
- package/src/verify-step.ts +20 -1
- package/src/verify-test-run.ts +47 -6
- package/src/verify-tests.ts +39 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.7.1",
|
|
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.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
41
|
+
"@ultimat3/action": "22.7.1",
|
|
42
|
+
"@ultimat3/admin": "22.7.1",
|
|
43
|
+
"@ultimat3/ai": "22.7.1",
|
|
44
|
+
"@ultimat3/auth": "22.7.1",
|
|
45
|
+
"@ultimat3/cache": "22.7.1",
|
|
46
|
+
"@ultimat3/core": "22.7.1",
|
|
47
|
+
"@ultimat3/db": "22.7.1",
|
|
48
|
+
"@ultimat3/entity": "22.7.1",
|
|
49
|
+
"@ultimat3/flags": "22.7.1",
|
|
50
|
+
"@ultimat3/http": "22.7.1",
|
|
51
|
+
"@ultimat3/i18n": "22.7.1",
|
|
52
|
+
"@ultimat3/jobs": "22.7.1",
|
|
53
|
+
"@ultimat3/mail": "22.7.1",
|
|
54
|
+
"@ultimat3/manifest": "22.7.1",
|
|
55
|
+
"@ultimat3/mcp": "22.7.1",
|
|
56
|
+
"@ultimat3/money": "22.7.1",
|
|
57
|
+
"@ultimat3/notify": "22.7.1",
|
|
58
|
+
"@ultimat3/policy": "22.7.1",
|
|
59
|
+
"@ultimat3/pwa": "22.7.1",
|
|
60
|
+
"@ultimat3/query": "22.7.1",
|
|
61
|
+
"@ultimat3/realtime": "22.7.1",
|
|
62
|
+
"@ultimat3/render": "22.7.1",
|
|
63
|
+
"@ultimat3/schema": "22.7.1",
|
|
64
|
+
"@ultimat3/seo": "22.7.1",
|
|
65
|
+
"@ultimat3/storage": "22.7.1",
|
|
66
|
+
"@ultimat3/testing": "22.7.1",
|
|
67
|
+
"@ultimat3/time": "22.7.1",
|
|
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
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// would be a duplicate name in its registry
|
|
98
|
-
// edits need a restart. Until 2026-09-
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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
|
|
105
|
-
//
|
|
106
|
-
|
|
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
|
-
|
|
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,
|
|
172
|
-
// registered module reads nothing here.
|
|
173
|
-
// evaluated from, and a read AFTER the import cannot know which bytes
|
|
174
|
-
// landing between the two bound V1's component to V2's hash, so the next
|
|
175
|
-
// do and served V1 until the save after. Read first, the worst case is one
|
|
176
|
-
// next tick, of a file that did change.
|
|
177
|
-
const
|
|
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
|
|
230
|
-
* `snapshot` is the source read before
|
|
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 (
|
|
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
|
|
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:
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
302
|
-
// re-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cmd-build-spec.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
package/src/cmd-dev-fixture.ts
CHANGED
|
@@ -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
|