@ultimat3/cli 19.2.0 → 19.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +108 -6
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/budgets.ts +17 -6
- package/src/cmd-dev.ts +29 -39
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +5 -2
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +11 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +59 -37
- package/src/doctor-offline.ts +122 -0
- package/src/error-catalog.ts +4 -5
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -1
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-host.ts +18 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +46 -20
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +5 -1
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +6 -6
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
package/src/sw-artifacts.ts
CHANGED
|
@@ -50,15 +50,41 @@ export interface ServiceWorkerInput {
|
|
|
50
50
|
* the offline fallback with no CSS is a page the visitor cannot read.
|
|
51
51
|
*/
|
|
52
52
|
readonly styles: StyleBundle;
|
|
53
|
+
/**
|
|
54
|
+
* What the build RENDERED, keyed by the route path it was rendered for. Optional because only a
|
|
55
|
+
* static export has documents to hash: `x dev` and the container emit the worker at boot, where
|
|
56
|
+
* no page has been built yet, and `buildPrecacheManifest` falls back to the build id for a route
|
|
57
|
+
* this map does not name.
|
|
58
|
+
*/
|
|
59
|
+
readonly documents?: ReadonlyMap<string, RenderedDocument>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One rendered document, as the precache manifest needs it.
|
|
64
|
+
*
|
|
65
|
+
* `revision` is `contentHash(html)` — `@ultimat3/render`'s own, the function that already stamps
|
|
66
|
+
* an ETag — and never the build id. `precache.ts`' header states the rule and nothing kept it:
|
|
67
|
+
* `pwaRoutes` projected four of `PwaRoute`'s eight fields, so every route entry read
|
|
68
|
+
* `{"url":"/","revision":"build-aaa","bytes":0}` and two deploys of a byte-identical site
|
|
69
|
+
* re-fetched every precached document. The zero was the second half — `DEFAULT_PRECACHE_WARN_BYTES`
|
|
70
|
+
* is a 5 MB budget over a total that could not count one byte of HTML.
|
|
71
|
+
*/
|
|
72
|
+
export interface RenderedDocument {
|
|
73
|
+
readonly revision: string;
|
|
74
|
+
readonly bytes: number;
|
|
53
75
|
}
|
|
54
76
|
|
|
55
77
|
/**
|
|
56
78
|
* The route table, as the service worker sees it. `api/` is dropped: an API response is a JSON
|
|
57
79
|
* document whose freshness is the app's business, and precaching one serves a stale answer to a
|
|
58
|
-
* client that had a network. Only the
|
|
59
|
-
* budgets and policy flags
|
|
80
|
+
* client that had a network. Only the fields a browser can act on cross — a descriptor carries
|
|
81
|
+
* budgets and policy flags it has no use for — plus, for a route this build rendered, the content
|
|
82
|
+
* hash and the byte count of the document it produced.
|
|
60
83
|
*/
|
|
61
|
-
const pwaRoutes = (
|
|
84
|
+
const pwaRoutes = (
|
|
85
|
+
routes: readonly RouteDescriptor[],
|
|
86
|
+
documents: ReadonlyMap<string, RenderedDocument>,
|
|
87
|
+
): readonly PwaRoute[] =>
|
|
62
88
|
// `flatMap` rather than `filter().map()`: the filter's predicate does not narrow `surface` for
|
|
63
89
|
// the map that follows it, and `PwaRoute` declares the two navigable surfaces only. A cast would
|
|
64
90
|
// hide the day a fifth surface arrives.
|
|
@@ -66,6 +92,8 @@ const pwaRoutes = (routes: readonly RouteDescriptor[]): readonly PwaRoute[] =>
|
|
|
66
92
|
// `shared/` is dropped with `api/`, and for a stronger reason: it is not a URL at all — the
|
|
67
93
|
// surface exists so two routes can import one module, and a browser can never navigate to it.
|
|
68
94
|
if (route.surface !== 'site' && route.surface !== 'app') return [];
|
|
95
|
+
// A `Map`, so a route path that happens to spell a prototype member cannot answer with one.
|
|
96
|
+
const document = documents.get(route.path);
|
|
69
97
|
return [
|
|
70
98
|
{
|
|
71
99
|
path: route.path,
|
|
@@ -73,6 +101,10 @@ const pwaRoutes = (routes: readonly RouteDescriptor[]): readonly PwaRoute[] =>
|
|
|
73
101
|
mode: route.mode,
|
|
74
102
|
offline: route.offline,
|
|
75
103
|
dynamic: route.dynamic,
|
|
104
|
+
// Absent rather than invented for a route no build rendered — an `ssr` page, or one this
|
|
105
|
+
// pass could not produce. `buildPrecacheManifest` then falls back to the build id, which
|
|
106
|
+
// is the honest answer when there are no bytes to hash.
|
|
107
|
+
...(document === undefined ? {} : { revision: document.revision, bytes: document.bytes }),
|
|
76
108
|
},
|
|
77
109
|
];
|
|
78
110
|
});
|
|
@@ -128,14 +160,34 @@ export function serviceWorkerArtifacts(
|
|
|
128
160
|
input: ServiceWorkerInput,
|
|
129
161
|
): ServiceWorkerArtifacts | undefined {
|
|
130
162
|
const pwa = input.pwa;
|
|
131
|
-
|
|
163
|
+
const head = serviceWorkerHead(pwa);
|
|
164
|
+
const fallback = pwa.offline.fallback;
|
|
165
|
+
// One predicate DECIDES — `serviceWorkerHead`, so a document can never name a script this
|
|
166
|
+
// function then declines to emit — and the `null` check is what NARROWS `fallback` below:
|
|
167
|
+
// TypeScript cannot learn a `string` from the other's answer, and a cast would hide the day the
|
|
168
|
+
// two stop agreeing.
|
|
169
|
+
if (head === undefined || fallback === null) return undefined;
|
|
170
|
+
const documents = input.documents ?? new Map<string, RenderedDocument>();
|
|
171
|
+
// The offline document is the one entry `buildPrecacheManifest` adds ITSELF, as
|
|
172
|
+
// `reason: 'fallback'`, ahead of every route — and `add()` keeps the first entry per url, so its
|
|
173
|
+
// revision is the one that decides. Without this pair it was the build id whatever the build
|
|
174
|
+
// knew, which re-downloaded the single page an offline navigation depends on on every deploy.
|
|
175
|
+
// Absent when this pass did not render the fallback (no route serves it — `x doctor` reports
|
|
176
|
+
// that as `X_PWA_NO_OFFLINE_FALLBACK`), and then `@ultimat3/pwa` falls back to the build id.
|
|
177
|
+
const fallbackDocument = documents.get(fallback);
|
|
132
178
|
const output = generateServiceWorker(
|
|
133
|
-
pwaRoutes(input.routes),
|
|
179
|
+
pwaRoutes(input.routes, documents),
|
|
134
180
|
{
|
|
135
181
|
scope: SW_SCOPE,
|
|
136
182
|
swPath: SERVICE_WORKER_PATH,
|
|
183
|
+
...(fallbackDocument === undefined
|
|
184
|
+
? {}
|
|
185
|
+
: {
|
|
186
|
+
offlineFallbackRevision: fallbackDocument.revision,
|
|
187
|
+
offlineFallbackBytes: fallbackDocument.bytes,
|
|
188
|
+
}),
|
|
137
189
|
offline: {
|
|
138
|
-
fallback
|
|
190
|
+
fallback,
|
|
139
191
|
...(pwa.offline.image === null ? {} : { image: pwa.offline.image }),
|
|
140
192
|
...(pwa.offline.font === null ? {} : { font: pwa.offline.font }),
|
|
141
193
|
neverCache: pwa.offline.neverCache,
|
|
@@ -148,7 +200,7 @@ export function serviceWorkerArtifacts(
|
|
|
148
200
|
return {
|
|
149
201
|
source: output.source,
|
|
150
202
|
register: registerSource(),
|
|
151
|
-
head
|
|
203
|
+
head,
|
|
152
204
|
precache: output.precache,
|
|
153
205
|
// `output.warnings` IS `output.precache.warnings` — the generator returns the manifest's list
|
|
154
206
|
// verbatim — so it is read once, not twice. The push line is this module's own, and it is the
|
|
@@ -160,6 +212,18 @@ export function serviceWorkerArtifacts(
|
|
|
160
212
|
};
|
|
161
213
|
}
|
|
162
214
|
|
|
215
|
+
/**
|
|
216
|
+
* The one `<script src>` a document needs, or `undefined` for an app that gets no worker.
|
|
217
|
+
*
|
|
218
|
+
* Separate from `serviceWorkerArtifacts` because the two are wanted at different moments: a static
|
|
219
|
+
* export has to put this tag in every document it renders, and the WORKER cannot be emitted until
|
|
220
|
+
* those documents exist — its precache manifest is built from their content hashes. One predicate
|
|
221
|
+
* for both (`offline.fallback === null` is `generateServiceWorker`'s refusal, spent early), so a
|
|
222
|
+
* document can never name a script the export does not carry.
|
|
223
|
+
*/
|
|
224
|
+
export const serviceWorkerHead = (pwa: PwaArtifacts): string | undefined =>
|
|
225
|
+
pwa.offline.fallback === null ? undefined : `<script src="${SW_REGISTER_PATH}" defer></script>`;
|
|
226
|
+
|
|
163
227
|
/**
|
|
164
228
|
* `pwa.push: true` with nothing to sign a subscription with. There is no `pwa.vapid` config key
|
|
165
229
|
* yet, so today this fires for EVERY app that sets the flag — deliberately: a switch that silently
|
|
@@ -9,6 +9,7 @@ import { sortedImports } from './imports';
|
|
|
9
9
|
import { catalogPath, resolveLocales } from './locales';
|
|
10
10
|
import type { GeneratedFile } from './naming';
|
|
11
11
|
import { camel, kebab, pascal } from './naming';
|
|
12
|
+
import { LINE_WIDTH } from './wrap';
|
|
12
13
|
|
|
13
14
|
/** Where an admin lives when the caller does not say. `x new` scaffolds this layout. */
|
|
14
15
|
export const DEFAULT_ADMIN_PAGE_DIR = 'apps/admin/src/pages';
|
|
@@ -58,9 +59,45 @@ const catalogImport = (module: string | undefined): string =>
|
|
|
58
59
|
const pageImports = (module: string | undefined): string =>
|
|
59
60
|
sortedImports([
|
|
60
61
|
`import type { AdminCustomPage, AdminPageProps } from '@ultimat3/admin';`,
|
|
62
|
+
`import { definePermissions } from '@ultimat3/policy';`,
|
|
61
63
|
catalogImport(module),
|
|
62
64
|
]);
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The page DECLARES the permission it requires, in both registries that decide about it.
|
|
68
|
+
*
|
|
69
|
+
* `x g admin:page ops` emitted `permissions: ['ops:read']` and nothing anywhere declared
|
|
70
|
+
* `ops:read`, so `assertPermission` threw X_PERMISSION_UNKNOWN on the first request that reached
|
|
71
|
+
* the page: a screen the generator built and no actor can open. Nothing catches it either — the
|
|
72
|
+
* gate's `policy` step reads `roleDefinitions()` and `routeEntries()`, and an admin page is
|
|
73
|
+
* neither a role nor a route, so `x verify` is green over it.
|
|
74
|
+
*
|
|
75
|
+
* Here rather than in a `policy.ts` beside it, because registration is a side effect of IMPORT and
|
|
76
|
+
* `pages:` already imports this module: a declaration in a file the admin does not import is a
|
|
77
|
+
* declaration that never runs. `declare module` is the type half — `can()`'s key type reads the
|
|
78
|
+
* registry, not the `definePermissions` call — and merging an identical member is what lets an app
|
|
79
|
+
* that already declares this permission keep its own declaration.
|
|
80
|
+
*/
|
|
81
|
+
const declarePermissions = (name: string, permission: string): string => {
|
|
82
|
+
const line = `export const ${camel(name)}PagePermissions = definePermissions(['${permission}']);`;
|
|
83
|
+
// Emitted PRE-formatted, because a template cannot run one: past 100 columns biome rewrites this
|
|
84
|
+
// call across four lines, and `x new zebra`-class names reach it (`emitted-contract.test.ts`
|
|
85
|
+
// varies both the length and the first letter for exactly this reason).
|
|
86
|
+
return line.length <= LINE_WIDTH
|
|
87
|
+
? line
|
|
88
|
+
: `export const ${camel(name)}PagePermissions = definePermissions([\n '${permission}',\n]);`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const permissionDeclaration = (name: string, permission: string): string => `
|
|
92
|
+
declare module '@ultimat3/policy' {
|
|
93
|
+
interface PermissionRegistry {
|
|
94
|
+
'${permission}': true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
${declarePermissions(name, permission)}
|
|
99
|
+
`;
|
|
100
|
+
|
|
64
101
|
/** `useT()` is per render, so the component binds it in its own body. */
|
|
65
102
|
const translatorBinding = (module: string | undefined): string =>
|
|
66
103
|
module === undefined ? '' : '\n const t = useT();\n';
|
|
@@ -81,9 +118,12 @@ const pageSource = (
|
|
|
81
118
|
// so the specifier is relative to wherever \`defineAdmin\` lives:
|
|
82
119
|
// import { ${declaration}Page } from './${name}';
|
|
83
120
|
// defineAdmin({ …, pages: […, ${declaration}Page] })
|
|
121
|
+
//
|
|
122
|
+
// The permission below is declared here AND has to be decided: an authz map built from a fixed
|
|
123
|
+
// list must name '${permission}' too, or a declared permission is still denied for everyone.
|
|
84
124
|
|
|
85
125
|
${pageImports(module)}
|
|
86
|
-
|
|
126
|
+
${permissionDeclaration(name, permission)}
|
|
87
127
|
export function ${Name}Page(props: AdminPageProps) {${translatorBinding(module)}
|
|
88
128
|
return (
|
|
89
129
|
<section>
|
|
@@ -108,6 +148,7 @@ const pageTest = (name: string, permission: string): string => {
|
|
|
108
148
|
const declaration = camel(name);
|
|
109
149
|
return `// The ${name} admin page is guarded and owns no route of its own — the two facts that separate an
|
|
110
150
|
// admin screen from a page, and the two an edit here is most likely to break.
|
|
151
|
+
import { knownPermissions } from '@ultimat3/policy';
|
|
111
152
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
112
153
|
import { ${declaration}Page } from './${name}';
|
|
113
154
|
|
|
@@ -119,6 +160,13 @@ unitTest('the ${name} admin page is rooted and guarded', () => {
|
|
|
119
160
|
expect(${declaration}Page.permissions).toContain('${permission}');
|
|
120
161
|
});
|
|
121
162
|
|
|
163
|
+
// A \`permissions:\` entry no \`definePermissions()\` declares is X_PERMISSION_UNKNOWN on the first
|
|
164
|
+
// request that reaches the page — a screen this generator built and nobody can open. Importing the
|
|
165
|
+
// page above is what registers it, which is why the declaration lives in that file and not beside it.
|
|
166
|
+
unitTest('the ${name} admin page declares its permission', () => {
|
|
167
|
+
expect(knownPermissions()).toContain('${permission}');
|
|
168
|
+
});
|
|
169
|
+
|
|
122
170
|
unitTest('the ${name} admin page declares no route of its own', () => {
|
|
123
171
|
// \`pages:\` is the only way in. A \`config\` export here would be a route the frame never guards.
|
|
124
172
|
expect('config' in ${declaration}Page).toBe(false);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// run-to-completion migrate step are conventions every container platform shares — a Heroku
|
|
7
7
|
// buildpack, a Render blueprint or a fly.toml would be the primitive that never ships.
|
|
8
8
|
|
|
9
|
+
import { SECRETS_KEY_FILE } from '@ultimat3/core';
|
|
9
10
|
import type { GeneratedFile, NameSet } from './naming';
|
|
10
11
|
import { helmFiles } from './scaffold-helm';
|
|
11
12
|
|
|
@@ -81,6 +82,12 @@ ENTRYPOINT ["bun", "apps/web/server.ts"]
|
|
|
81
82
|
* tells the operator to create, in its own `env_file:` — nor `.env.development`, and both landed in
|
|
82
83
|
* an image layer that `cache-to=mode=max` then pushes to a shared cache. Same four lines the
|
|
83
84
|
* framework's own `docker/Dockerfile.dockerignore` carries, and for the same reason.
|
|
85
|
+
*
|
|
86
|
+
* The key file is interpolated from `SECRETS_KEY_FILE` rather than written out, because the
|
|
87
|
+
* constant is what `findMasterKey` reads: a rename would otherwise leave every app this generator
|
|
88
|
+
* has ever produced ignoring a filename nothing writes, which reads as a rule still in force.
|
|
89
|
+
* `scripts/image-contract.ts` holds every ignore file IN THIS TREE to the same line; a generated
|
|
90
|
+
* app's copy is this template's, and `scaffold-container.test.ts` is where it is judged.
|
|
84
91
|
*/
|
|
85
92
|
const dockerignore = (): string => `**/.env
|
|
86
93
|
**/.env.*
|
|
@@ -88,6 +95,11 @@ const dockerignore = (): string => `**/.env
|
|
|
88
95
|
# Same shape, different file: an .npmrc carries a registry auth token, so any that exists in a
|
|
89
96
|
# build context is somebody's local credential and has no business in a layer.
|
|
90
97
|
**/.npmrc
|
|
98
|
+
# Same shape, and this one is the key itself: ${SECRETS_KEY_FILE} decrypts the committed
|
|
99
|
+
# secrets.enc.json beside it, and findMasterKey falls back to the file whenever
|
|
100
|
+
# ULTIMATE_SECRETS_KEY is unset — so a baked copy boots the image on it and the platform's key is
|
|
101
|
+
# never exercised.
|
|
102
|
+
**/${SECRETS_KEY_FILE}
|
|
91
103
|
|
|
92
104
|
node_modules
|
|
93
105
|
**/node_modules
|
|
@@ -333,13 +333,18 @@ declare module '*.scss' {
|
|
|
333
333
|
}
|
|
334
334
|
`;
|
|
335
335
|
|
|
336
|
+
// Build output is ROOT-ANCHORED, and that is not cosmetic: an unanchored `dist/` or `coverage/`
|
|
337
|
+
// matches a directory of that name at ANY depth, so `apps/web/site/dist/page.tsx` — an app's own
|
|
338
|
+
// `/dist` route, the directory IS the URL — was a file git refused to commit and `x dev` refused to
|
|
339
|
+
// reload. `packages/*/dist/` keeps a workspace's build output ignored without reaching a surface.
|
|
336
340
|
const gitignore = (): string => `node_modules/
|
|
337
341
|
.x/
|
|
338
|
-
dist/
|
|
342
|
+
/dist/
|
|
343
|
+
packages/*/dist/
|
|
339
344
|
*.tsbuildinfo
|
|
340
345
|
.env
|
|
341
346
|
.env.*.local
|
|
342
|
-
coverage/
|
|
347
|
+
/coverage/
|
|
343
348
|
playwright-report/
|
|
344
349
|
test-results/
|
|
345
350
|
`;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Which of a selection's files may share a worker pool, and which may not. `--parallel=N` is the
|
|
2
|
+
// width of the WHOLE run, so a selection holding a `live` or an `e2e` file is more than one run —
|
|
3
|
+
// this file decides how many, and `test-shards.ts` spends them.
|
|
4
|
+
|
|
5
|
+
import type { TestFile } from './test-select';
|
|
6
|
+
import type { TestType } from './verify-tests';
|
|
7
|
+
import { ownerOf, SERIAL_TYPES } from './verify-tests';
|
|
8
|
+
|
|
9
|
+
/** One `bun test` invocation: which files, how wide, and the type its reproduce line names. */
|
|
10
|
+
export interface TestPass {
|
|
11
|
+
readonly files: readonly TestFile[];
|
|
12
|
+
readonly workers: number;
|
|
13
|
+
/**
|
|
14
|
+
* The type this pass is exactly the selection of, when it is one — so a failure's `fix:` names
|
|
15
|
+
* `x test live --workers 1` and reruns THESE files, not the whole corpus at this width.
|
|
16
|
+
*/
|
|
17
|
+
readonly type?: TestType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PassInput {
|
|
21
|
+
readonly files: readonly TestFile[];
|
|
22
|
+
/** What the caller asked for, already bounded by `--workers`' own reader. */
|
|
23
|
+
readonly workers: number;
|
|
24
|
+
/** The positional, when there was one. A pass over one type keeps it; the split never adds one. */
|
|
25
|
+
readonly type?: TestType;
|
|
26
|
+
/** Set for a `--worker I` rerun, which is one process by construction. */
|
|
27
|
+
readonly shard?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const widthFor = (files: readonly TestFile[], workers: number): number =>
|
|
31
|
+
Math.max(1, Math.min(Math.trunc(workers), files.length || 1));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The passes one invocation becomes. One, for every selection that holds no serial file — which
|
|
35
|
+
* is every `x test unit`, every `--filter` over a feature's contract tests, and the whole corpus
|
|
36
|
+
* of an app that has neither.
|
|
37
|
+
*
|
|
38
|
+
* `live` and `e2e` are the exception, and `verify-tests.ts` owns both the list and the reasons: a
|
|
39
|
+
* logical replication slot is named at the Postgres CLUSTER level so a per-worker database does
|
|
40
|
+
* not isolate it, and `e2e` shares one `dist/` and one browser profile. `x verify` has routed them
|
|
41
|
+
* through `runSerial` since 2026-08 while `x test` clamped on the POSITIONAL alone — so a bare
|
|
42
|
+
* `x test --workers 8` ran the very files the gate runs one at a time, eight at a time, and only
|
|
43
|
+
* a real `TEST_DATABASE_URL` makes that visible.
|
|
44
|
+
*
|
|
45
|
+
* Each serial type is its own pass rather than one pass over both, because the pass's `type` is
|
|
46
|
+
* what its failure reproduces with: `x test live --workers 1` selects exactly the files that ran.
|
|
47
|
+
*
|
|
48
|
+
* A `--worker I` rerun is left whole: it is a single `bun test --isolate --shard=i/N` process, so
|
|
49
|
+
* nothing inside it runs beside anything else, and splitting it would make shard i of the rerun a
|
|
50
|
+
* different set of files from shard i of the run it reproduces.
|
|
51
|
+
*/
|
|
52
|
+
export function testPasses(input: PassInput): readonly TestPass[] {
|
|
53
|
+
const serialTypes = new Set<TestType>(SERIAL_TYPES);
|
|
54
|
+
if (input.shard !== undefined) {
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
files: input.files,
|
|
58
|
+
workers: widthFor(input.files, input.workers),
|
|
59
|
+
...(input.type === undefined ? {} : { type: input.type }),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
const shared = input.files.filter((file) => !serialTypes.has(ownerOf(file.path)));
|
|
64
|
+
const passes: TestPass[] = [];
|
|
65
|
+
// Cheapest first, and the widest first: the pool run is most of the corpus and most of the
|
|
66
|
+
// signal, and a serial suite that needs a database is the one a laptop is least likely to have.
|
|
67
|
+
if (shared.length > 0) {
|
|
68
|
+
passes.push({
|
|
69
|
+
files: shared,
|
|
70
|
+
workers: widthFor(shared, input.workers),
|
|
71
|
+
...(input.type === undefined ? {} : { type: input.type }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
for (const type of SERIAL_TYPES) {
|
|
75
|
+
const files = input.files.filter((file) => ownerOf(file.path) === type);
|
|
76
|
+
if (files.length > 0) passes.push({ files, workers: 1, type });
|
|
77
|
+
}
|
|
78
|
+
return passes;
|
|
79
|
+
}
|
package/src/test-shards.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { execOutput } from './exec';
|
|
|
40
40
|
import { msg } from './messages';
|
|
41
41
|
import type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
42
42
|
import { quoteArg } from './shell-quote';
|
|
43
|
+
import { testPasses } from './test-passes';
|
|
43
44
|
import type { TestFile } from './test-select';
|
|
44
45
|
import type { TestType } from './verify-tests';
|
|
45
46
|
|
|
@@ -65,15 +66,26 @@ export function testArgs(input: {
|
|
|
65
66
|
readonly workers: number;
|
|
66
67
|
/** 0-based, matching `--worker`. Absent runs the whole selection across `workers` processes. */
|
|
67
68
|
readonly shard?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Everything after a bare `--`, handed to `bun test` verbatim and BEFORE the file list, which is
|
|
71
|
+
* where bun reads its flags. `ParsedArgs.passthrough` had no reader anywhere until 2026-09, so
|
|
72
|
+
* `x test unit -- --coverage --bail` parsed both flags, carried them through the command and
|
|
73
|
+
* dropped them on the floor — a run that reported exactly what a coverage run reports, with no
|
|
74
|
+
* coverage measured. `CommandSpec.passthrough` is what keeps the other commands from doing the
|
|
75
|
+
* same in silence: they refuse the `--` instead.
|
|
76
|
+
*/
|
|
77
|
+
readonly passthrough?: readonly string[];
|
|
68
78
|
}): readonly string[] {
|
|
69
79
|
const files = [...input.files].sort();
|
|
80
|
+
const extra = input.passthrough ?? [];
|
|
70
81
|
return input.shard === undefined
|
|
71
|
-
? ['bun', 'test', `--parallel=${String(input.workers)}`, ...files]
|
|
82
|
+
? ['bun', 'test', `--parallel=${String(input.workers)}`, ...extra, ...files]
|
|
72
83
|
: [
|
|
73
84
|
'bun',
|
|
74
85
|
'test',
|
|
75
86
|
'--isolate',
|
|
76
87
|
`--shard=${String(input.shard + 1)}/${String(input.workers)}`,
|
|
88
|
+
...extra,
|
|
77
89
|
...files,
|
|
78
90
|
];
|
|
79
91
|
}
|
|
@@ -102,6 +114,8 @@ export interface ReproduceOptions {
|
|
|
102
114
|
readonly affected?: AffectedSelection;
|
|
103
115
|
/** 0-based, when reproducing ONE shard. Absent reproduces the whole selection. */
|
|
104
116
|
readonly shard?: number;
|
|
117
|
+
/** What the caller put after `--`. It reaches `bun test`, so a rerun without it runs differently. */
|
|
118
|
+
readonly passthrough?: readonly string[];
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
/**
|
|
@@ -124,6 +138,11 @@ export function reproduceFor(options: ReproduceOptions): string {
|
|
|
124
138
|
'--workers',
|
|
125
139
|
String(options.workers),
|
|
126
140
|
...(options.shard === undefined ? [] : ['--worker', String(options.shard)]),
|
|
141
|
+
// Last, and after a `--` of its own, because that is where the caller typed it and where the
|
|
142
|
+
// parser will find it again. Quoted for `shell-quote.ts`'s reason: a reproduce line is pasted.
|
|
143
|
+
...(options.passthrough === undefined || options.passthrough.length === 0
|
|
144
|
+
? []
|
|
145
|
+
: ['--', ...options.passthrough.map(quoteArg)]),
|
|
127
146
|
].join(' ');
|
|
128
147
|
}
|
|
129
148
|
|
|
@@ -144,16 +163,28 @@ export interface RunShardsOptions {
|
|
|
144
163
|
readonly sample?: { readonly kept: number; readonly total: number };
|
|
145
164
|
/** Passed straight to `reproduceFor`: see `ReproduceOptions.affected`. */
|
|
146
165
|
readonly affected?: AffectedSelection;
|
|
166
|
+
/** Everything after the caller's `--`, forwarded to every pass and printed in the reproduce. */
|
|
167
|
+
readonly passthrough?: readonly string[];
|
|
147
168
|
}
|
|
148
169
|
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
170
|
+
/**
|
|
171
|
+
* The reproduction's inputs for ONE pass: `workers` is that pass's real width, not the ask, and
|
|
172
|
+
* `type` is the pass's own when the split gave it one — `x test live --workers 1` reruns exactly
|
|
173
|
+
* the files that failed, where the whole invocation's flags would rerun the corpus around them.
|
|
174
|
+
*/
|
|
175
|
+
const planOf = (
|
|
176
|
+
options: RunShardsOptions,
|
|
177
|
+
pass: { readonly workers: number; readonly type?: TestType },
|
|
178
|
+
): ReproduceOptions => ({
|
|
179
|
+
workers: pass.workers,
|
|
152
180
|
...(options.filter === undefined ? {} : { filter: options.filter }),
|
|
153
|
-
...(
|
|
181
|
+
...(pass.type === undefined ? {} : { type: pass.type }),
|
|
154
182
|
...(options.sample === undefined ? {} : { sample: options.sample.kept }),
|
|
155
183
|
...(options.affected === undefined ? {} : { affected: options.affected }),
|
|
156
184
|
...(options.only === undefined ? {} : { shard: options.only }),
|
|
185
|
+
...(options.passthrough === undefined || options.passthrough.length === 0
|
|
186
|
+
? {}
|
|
187
|
+
: { passthrough: options.passthrough }),
|
|
157
188
|
});
|
|
158
189
|
|
|
159
190
|
/**
|
|
@@ -173,68 +204,111 @@ export const failureOf = (code: number, files: number, plan: ReproduceOptions):
|
|
|
173
204
|
});
|
|
174
205
|
|
|
175
206
|
/**
|
|
176
|
-
* ONE `bun test
|
|
177
|
-
*
|
|
207
|
+
* ONE `bun test` PER PASS, and one pass unless the selection mixes serial files with the rest —
|
|
208
|
+
* `test-passes.ts` decides that, and this spends it. Bun owns the pool inside a pass and hands
|
|
209
|
+
* each free worker the next file, so nothing here decides which file runs where; see this file's
|
|
210
|
+
* header for what that measured.
|
|
211
|
+
*
|
|
212
|
+
* Sequential, never `Promise.all`: the whole point of a serial pass is that nothing runs beside
|
|
213
|
+
* it. And every pass runs even after one fails — the caller asked for a suite, and a report that
|
|
214
|
+
* stops at the first red step hides the rest of the answer.
|
|
178
215
|
*
|
|
179
216
|
* `ULTIMATE_TEST_WORKER` is still set for a `--worker` rerun and only then: that run is one
|
|
180
217
|
* process, so naming its database is this file's to do. A `--parallel` run has N of them and Bun
|
|
181
218
|
* numbers each with `BUN_TEST_WORKER_ID`, which `@ultimat3/testing`'s `workerId` already reads.
|
|
182
219
|
*/
|
|
183
220
|
export async function runShards(options: RunShardsOptions): Promise<CommandResult> {
|
|
184
|
-
const files = options.files.map((file) => file.path);
|
|
185
|
-
const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
|
|
186
221
|
const only = options.only;
|
|
222
|
+
const passes = testPasses({
|
|
223
|
+
files: options.files,
|
|
224
|
+
workers: options.workers,
|
|
225
|
+
...(options.type === undefined ? {} : { type: options.type }),
|
|
226
|
+
...(only === undefined ? {} : { shard: only }),
|
|
227
|
+
});
|
|
187
228
|
const started = performance.now();
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
229
|
+
const steps: StepResult[] = [];
|
|
230
|
+
const spent: JsonValue[] = [];
|
|
231
|
+
let ok = true;
|
|
232
|
+
let exitCode = 0;
|
|
233
|
+
for (const pass of passes) {
|
|
234
|
+
const files = pass.files.map((file) => file.path);
|
|
235
|
+
const result = await options.runner(
|
|
236
|
+
testArgs({
|
|
237
|
+
files,
|
|
238
|
+
workers: pass.workers,
|
|
239
|
+
...(only === undefined ? {} : { shard: only }),
|
|
240
|
+
...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
|
|
241
|
+
}),
|
|
242
|
+
{
|
|
243
|
+
cwd: options.root,
|
|
244
|
+
...(only === undefined ? {} : { env: { ULTIMATE_TEST_WORKER: String(only) } }),
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
const plan = planOf(options, pass);
|
|
248
|
+
const label =
|
|
249
|
+
only === undefined ? `${pass.workers} worker(s)` : `shard ${only} of ${pass.workers}`;
|
|
250
|
+
steps.push({
|
|
251
|
+
name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files`,
|
|
204
252
|
ok: result.ok,
|
|
205
253
|
durationMs: result.durationMs,
|
|
206
254
|
// `output.ts` documents this field as absent for a NON-test step, so omitting it here made
|
|
207
255
|
// `renderJson` describe the test step as one — recoverable only by parsing `name`.
|
|
208
|
-
workers,
|
|
256
|
+
workers: pass.workers,
|
|
209
257
|
findings: result.ok ? [] : [failureOf(result.code, files.length, plan)],
|
|
210
258
|
output: execOutput(result),
|
|
211
|
-
}
|
|
212
|
-
|
|
259
|
+
});
|
|
260
|
+
spent.push({
|
|
261
|
+
...(pass.type === undefined ? {} : { type: pass.type }),
|
|
262
|
+
files: files.length,
|
|
263
|
+
workers: pass.workers,
|
|
264
|
+
ok: result.ok,
|
|
265
|
+
exitCode: result.code,
|
|
266
|
+
reproduce: reproduceFor(plan),
|
|
267
|
+
});
|
|
268
|
+
ok = ok && result.ok;
|
|
269
|
+
if (exitCode === 0) exitCode = result.code;
|
|
270
|
+
}
|
|
271
|
+
const durationMs = Math.round(performance.now() - started);
|
|
272
|
+
const fileCount = options.files.length;
|
|
273
|
+
// The width the RUN reached, which is the widest pass: a mixed selection whose serial half ran
|
|
274
|
+
// one at a time did not become a one-worker run, and reporting it as one would misname the
|
|
275
|
+
// reproduce a reader is handed.
|
|
276
|
+
const workers = Math.max(1, ...passes.map((pass) => pass.workers));
|
|
277
|
+
const plan = planOf(options, {
|
|
278
|
+
workers,
|
|
279
|
+
...(options.type === undefined ? {} : { type: options.type }),
|
|
280
|
+
});
|
|
281
|
+
const type = options.type;
|
|
282
|
+
const typeParam = type === undefined ? {} : { type };
|
|
283
|
+
const sample = options.sample;
|
|
213
284
|
const data: JsonValue = {
|
|
214
285
|
...typeParam,
|
|
215
286
|
workers,
|
|
216
|
-
files:
|
|
287
|
+
files: fileCount,
|
|
217
288
|
durationMs,
|
|
218
289
|
...(options.filter === undefined ? {} : { filter: options.filter }),
|
|
219
290
|
...(sample === undefined ? {} : { sample: { kept: sample.kept, total: sample.total } }),
|
|
220
291
|
...(only === undefined ? {} : { shard: only }),
|
|
221
|
-
|
|
222
|
-
|
|
292
|
+
// Only when the split made more than one, so a single-pass run's JSON is byte-identical to
|
|
293
|
+
// what it has always been — and a mixed one can never be read as if it were a single run.
|
|
294
|
+
...(spent.length > 1 ? { passes: spent } : {}),
|
|
295
|
+
ok,
|
|
296
|
+
exitCode,
|
|
223
297
|
reproduce: reproduceFor(plan),
|
|
224
298
|
};
|
|
225
299
|
return {
|
|
226
|
-
ok
|
|
300
|
+
ok,
|
|
227
301
|
command: 'test',
|
|
228
|
-
summary:
|
|
302
|
+
summary: ok
|
|
229
303
|
? msg(type === undefined ? 'cli.test.pass' : 'cli.test.type.pass', {
|
|
230
304
|
...typeParam,
|
|
231
|
-
files:
|
|
305
|
+
files: fileCount,
|
|
232
306
|
workers,
|
|
233
307
|
ms: durationMs,
|
|
234
308
|
})
|
|
235
309
|
: msg(type === undefined ? 'cli.test.fail' : 'cli.test.type.fail', {
|
|
236
310
|
...typeParam,
|
|
237
|
-
failed:
|
|
311
|
+
failed: steps.filter((step) => !step.ok).length,
|
|
238
312
|
workers,
|
|
239
313
|
}),
|
|
240
314
|
steps,
|
|
@@ -242,6 +316,6 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
242
316
|
? {}
|
|
243
317
|
: { lines: [msg('cli.test.sampled', { ...sample, type: type ?? 'all' })] }),
|
|
244
318
|
data,
|
|
245
|
-
exitCode:
|
|
319
|
+
exitCode: ok ? 0 : 1,
|
|
246
320
|
};
|
|
247
321
|
}
|
package/src/verify-checks.ts
CHANGED
|
@@ -80,7 +80,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
80
80
|
{
|
|
81
81
|
name: 'boundaries',
|
|
82
82
|
summary: "surface, layer and package-tier imports, and the app's own guards",
|
|
83
|
-
// An app's `guards/` rides here rather than becoming
|
|
83
|
+
// An app's `guards/` rides here rather than becoming a step of its own, for the reason the
|
|
84
84
|
// seam already states: a host adds findings to a step, it can never add, remove, reorder or
|
|
85
85
|
// skip one — so "green" keeps meaning exactly what it meant. This is the step whose host slot
|
|
86
86
|
// already carries "rules this repo makes about itself that the framework cannot know" (the
|
|
@@ -106,7 +106,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
106
106
|
name: 'package-shape',
|
|
107
107
|
summary: 'every package ships the same contract files',
|
|
108
108
|
applies: (ctx) => hasWorkspacePackages(ctx.root),
|
|
109
|
-
// The dependency rule rides here rather than becoming a
|
|
109
|
+
// The dependency rule rides here rather than becoming a step of its own because it is this
|
|
110
110
|
// step's own question — what does a workspace owe the repo it lives in? — asked of the
|
|
111
111
|
// manifest's `dependencies` instead of its `files`. It is deliberately NOT inside
|
|
112
112
|
// `checkPackageShape`: `scripts/release.ts --check` calls that one to ask whether the tree is
|
|
@@ -155,7 +155,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
155
155
|
// Source, not database: the gate runs in CI with nothing listening, and the database half is
|
|
156
156
|
// the post-migrate verification `runMigrations` performs where a connection is already open.
|
|
157
157
|
//
|
|
158
|
-
// The destructive rail rides here rather than becoming
|
|
158
|
+
// The destructive rail rides here rather than becoming a step of its own because it asks this
|
|
159
159
|
// step's own question — do the committed migrations still describe what the app is doing to its
|
|
160
160
|
// schema? — off the same directory, in the same pass, with no database either.
|
|
161
161
|
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
@@ -166,7 +166,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
166
166
|
// The third rail, and the one the other two cannot see: a hand-written statement is
|
|
167
167
|
// recorded by no snapshot and hashed by no source, so both halves above are green over SQL
|
|
168
168
|
// a squash silently drops. Same directory, same reader, no database — this step's own
|
|
169
|
-
// question, which is why it is not
|
|
169
|
+
// question, which is why it is not a step of its own.
|
|
170
170
|
...(await checkUngeneratableMigrations(ctx.root)),
|
|
171
171
|
]),
|
|
172
172
|
},
|
|
@@ -191,7 +191,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
191
191
|
name: 'budgets',
|
|
192
192
|
summary:
|
|
193
193
|
'per-route JS bytes and LCP, the global style layer every document carries, and the routes that boot nothing to receive their live rows',
|
|
194
|
-
// The global-style assertion rides here rather than becoming
|
|
194
|
+
// The global-style assertion rides here rather than becoming a step of its own, because this
|
|
195
195
|
// step already asks the one question it asks: what does the document this build emits actually
|
|
196
196
|
// contain? It is also the same app load — `appManifest` fills render's stylesheet registry on
|
|
197
197
|
// its way through — so a separate step would pay for a second one to answer half a question.
|
|
@@ -295,7 +295,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
295
295
|
// once, and says so by finding nothing — but `AGENTS.md` is required of every repo the gate
|
|
296
296
|
// runs in, so the step always has a question to answer and must never report as skipped.
|
|
297
297
|
//
|
|
298
|
-
// `.env.example` joins this step rather than becoming
|
|
298
|
+
// `.env.example` joins this step rather than becoming one of its own: the question is the same
|
|
299
299
|
// one — "does a committed, generated file still describe the code?" — and the step list is the
|
|
300
300
|
// definition of shippable, so it grows only when a genuinely new question needs asking.
|
|
301
301
|
//
|