@ultimat3/cli 20.0.0 → 20.1.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 +29 -29
- package/src/budgets.ts +47 -2
- package/src/cmd-doctor.ts +97 -3
- package/src/e2e-driver.ts +2 -1
- package/src/index.ts +8 -2
- package/src/messages.ts +8 -0
- package/src/output.ts +29 -1
- package/src/prerender.ts +16 -1
- package/src/sw-artifacts.ts +9 -2
- package/src/templates/github/ci.yml.ts +74 -0
- package/src/templates/scaffold-claude-commands.ts +20 -9
- package/src/templates/scaffold-docs.ts +29 -5
- package/src/templates/scaffold-repo.ts +13 -2
- package/src/verify-run.ts +13 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.1.1",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,34 +37,34 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "20.
|
|
41
|
-
"@ultimat3/admin": "20.
|
|
42
|
-
"@ultimat3/ai": "20.
|
|
43
|
-
"@ultimat3/auth": "20.
|
|
44
|
-
"@ultimat3/cache": "20.
|
|
45
|
-
"@ultimat3/core": "20.
|
|
46
|
-
"@ultimat3/db": "20.
|
|
47
|
-
"@ultimat3/entity": "20.
|
|
48
|
-
"@ultimat3/flags": "20.
|
|
49
|
-
"@ultimat3/http": "20.
|
|
50
|
-
"@ultimat3/i18n": "20.
|
|
51
|
-
"@ultimat3/jobs": "20.
|
|
52
|
-
"@ultimat3/mail": "20.
|
|
53
|
-
"@ultimat3/manifest": "20.
|
|
54
|
-
"@ultimat3/mcp": "20.
|
|
55
|
-
"@ultimat3/money": "20.
|
|
56
|
-
"@ultimat3/notify": "20.
|
|
57
|
-
"@ultimat3/policy": "20.
|
|
58
|
-
"@ultimat3/pwa": "20.
|
|
59
|
-
"@ultimat3/query": "20.
|
|
60
|
-
"@ultimat3/realtime": "20.
|
|
61
|
-
"@ultimat3/render": "20.
|
|
62
|
-
"@ultimat3/schema": "20.
|
|
63
|
-
"@ultimat3/scraping": "20.
|
|
64
|
-
"@ultimat3/seo": "20.
|
|
65
|
-
"@ultimat3/storage": "20.
|
|
66
|
-
"@ultimat3/testing": "20.
|
|
67
|
-
"@ultimat3/time": "20.
|
|
40
|
+
"@ultimat3/action": "20.1.1",
|
|
41
|
+
"@ultimat3/admin": "20.1.1",
|
|
42
|
+
"@ultimat3/ai": "20.1.1",
|
|
43
|
+
"@ultimat3/auth": "20.1.1",
|
|
44
|
+
"@ultimat3/cache": "20.1.1",
|
|
45
|
+
"@ultimat3/core": "20.1.1",
|
|
46
|
+
"@ultimat3/db": "20.1.1",
|
|
47
|
+
"@ultimat3/entity": "20.1.1",
|
|
48
|
+
"@ultimat3/flags": "20.1.1",
|
|
49
|
+
"@ultimat3/http": "20.1.1",
|
|
50
|
+
"@ultimat3/i18n": "20.1.1",
|
|
51
|
+
"@ultimat3/jobs": "20.1.1",
|
|
52
|
+
"@ultimat3/mail": "20.1.1",
|
|
53
|
+
"@ultimat3/manifest": "20.1.1",
|
|
54
|
+
"@ultimat3/mcp": "20.1.1",
|
|
55
|
+
"@ultimat3/money": "20.1.1",
|
|
56
|
+
"@ultimat3/notify": "20.1.1",
|
|
57
|
+
"@ultimat3/policy": "20.1.1",
|
|
58
|
+
"@ultimat3/pwa": "20.1.1",
|
|
59
|
+
"@ultimat3/query": "20.1.1",
|
|
60
|
+
"@ultimat3/realtime": "20.1.1",
|
|
61
|
+
"@ultimat3/render": "20.1.1",
|
|
62
|
+
"@ultimat3/schema": "20.1.1",
|
|
63
|
+
"@ultimat3/scraping": "20.1.1",
|
|
64
|
+
"@ultimat3/seo": "20.1.1",
|
|
65
|
+
"@ultimat3/storage": "20.1.1",
|
|
66
|
+
"@ultimat3/testing": "20.1.1",
|
|
67
|
+
"@ultimat3/time": "20.1.1",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/budgets.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { Manifest, RouteFact } from '@ultimat3/manifest';
|
|
|
13
13
|
import { formatBytes, parseByteBudget } from '@ultimat3/render';
|
|
14
14
|
import type { Finding } from './output';
|
|
15
15
|
import type { UnmeasuredRoute } from './static-report';
|
|
16
|
+
import { SW_REGISTER_PATH } from './sw-artifacts';
|
|
16
17
|
|
|
17
18
|
export const BUILD_STATS_FILE = join('.x', 'build-stats.json');
|
|
18
19
|
|
|
@@ -24,7 +25,14 @@ export interface RouteStats {
|
|
|
24
25
|
* prerenders many pages contributes ONE row, holding its heaviest.
|
|
25
26
|
*/
|
|
26
27
|
readonly path: string;
|
|
28
|
+
/** The APP's JavaScript, and only the app's — see `FRAMEWORK_SCRIPTS`. */
|
|
27
29
|
readonly jsBytes: number;
|
|
30
|
+
/**
|
|
31
|
+
* The framework's own injected runtime, in bytes: reported, never budgeted. Optional because a
|
|
32
|
+
* stats file written before 2026-09-11 has no such key, and absent is not zero — a row from an
|
|
33
|
+
* older build simply did not count it, and `checkBudgets` reads `jsBytes` either way.
|
|
34
|
+
*/
|
|
35
|
+
readonly frameworkJsBytes?: number;
|
|
28
36
|
/**
|
|
29
37
|
* **Written by nothing, `As of 2026-08`.** `apps/web/prerender.ts` is the only producer of this
|
|
30
38
|
* file and it emits static HTML — there is no browser in the build to observe a paint. So the
|
|
@@ -212,11 +220,39 @@ export interface MeasuredEntry {
|
|
|
212
220
|
}
|
|
213
221
|
|
|
214
222
|
export interface MeasuredJs {
|
|
223
|
+
/** The app's own executable bytes — what `budget.js` is a promise about. */
|
|
215
224
|
readonly jsBytes: number;
|
|
216
|
-
/**
|
|
225
|
+
/** The framework's injected runtime, counted separately so it is reported and never charged. */
|
|
226
|
+
readonly frameworkBytes: number;
|
|
227
|
+
/** Every APP `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
|
|
217
228
|
readonly entries: readonly MeasuredEntry[];
|
|
218
229
|
}
|
|
219
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Scripts the FRAMEWORK injects into a document, which a route's `budget.js` does not answer for.
|
|
233
|
+
* `budget.js` is a promise about the APP's JavaScript: an author can delete an import, move one
|
|
234
|
+
* behind `hydrate: 'interaction'` or drop an island, and can do NOTHING about a file the build
|
|
235
|
+
* writes into every document it renders. Charging it made `js: '0kb'` — the budget `x new`
|
|
236
|
+
* scaffolds on `site/` — unreachable for any installable app, and the `fix:` it printed named an
|
|
237
|
+
* import chain of one entry the author never wrote.
|
|
238
|
+
*
|
|
239
|
+
* It was also not a stable number. `/x-sw-register.js` is written AFTER the documents that name
|
|
240
|
+
* it are weighed (`prerender.ts` emits the worker last, because its precache manifest is built
|
|
241
|
+
* from their content hashes), so a clean `.x/` measured a file that did not exist and recorded 0,
|
|
242
|
+
* and the next build measured the one before it and recorded 250. Same commit, green then red,
|
|
243
|
+
* decided by whether anything had cleaned the output directory.
|
|
244
|
+
*
|
|
245
|
+
* ENUMERATED, and it is one entry: `serviceWorkerHead` is the only `<script src>` the framework
|
|
246
|
+
* puts in a prerendered document — measured on a fresh scaffold plus `x g island` and
|
|
247
|
+
* `x g route`, where `<script src="/x-sw-register.js" defer>` is the only script tag across every
|
|
248
|
+
* emitted page. Two framework scripts are deliberately NOT here. `render/src/hydrate.ts`'s inline
|
|
249
|
+
* module runtime is charged, because it exists only when the page ships an island — it is the
|
|
250
|
+
* cost of the app's own interactivity, and a page with a `0kb` budget has none. `island-props.ts`'
|
|
251
|
+
* `<script type="application/json">` is already excluded as data, by `carriesJson`. A third one
|
|
252
|
+
* joins this set by a decision, here, with the same argument.
|
|
253
|
+
*/
|
|
254
|
+
export const FRAMEWORK_SCRIPTS: ReadonlySet<string> = new Set([SW_REGISTER_PATH]);
|
|
255
|
+
|
|
220
256
|
/**
|
|
221
257
|
* What a rendered document actually makes the browser execute: the bytes of every inline script
|
|
222
258
|
* the parser will run, the size of every file a `src` points at, and the size of every island
|
|
@@ -226,6 +262,7 @@ export interface MeasuredJs {
|
|
|
226
262
|
*/
|
|
227
263
|
export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
|
|
228
264
|
let jsBytes = 0;
|
|
265
|
+
let frameworkBytes = 0;
|
|
229
266
|
const entries: MeasuredEntry[] = [];
|
|
230
267
|
// Deduped ONCE, across both readers below, and the unit is the FETCH: a browser downloads a URL
|
|
231
268
|
// once however many times the document names it, so `budget.js` — a byte budget — counts it
|
|
@@ -247,6 +284,14 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
247
284
|
fetched.add(url);
|
|
248
285
|
const file = Bun.file(join(out, url.slice(1)));
|
|
249
286
|
const bytes = (await file.exists()) ? file.size : 0;
|
|
287
|
+
// Counted and set aside, not skipped: the bytes are real and a reader is owed the number.
|
|
288
|
+
// Kept out of `entries` as well as out of `jsBytes`, because `entries` is what a finding reads
|
|
289
|
+
// to name the heaviest import — and on a fresh scaffold every route's `heaviestChain` was
|
|
290
|
+
// `/x-sw-register.js`, a file the author cannot edit, delete or move.
|
|
291
|
+
if (FRAMEWORK_SCRIPTS.has(url)) {
|
|
292
|
+
frameworkBytes += bytes;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
250
295
|
entries.push({ url, bytes });
|
|
251
296
|
jsBytes += bytes;
|
|
252
297
|
};
|
|
@@ -266,7 +311,7 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
266
311
|
if (url === undefined) continue;
|
|
267
312
|
await weigh(url);
|
|
268
313
|
}
|
|
269
|
-
return { jsBytes, entries };
|
|
314
|
+
return { jsBytes, frameworkBytes, entries };
|
|
270
315
|
}
|
|
271
316
|
|
|
272
317
|
/**
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
// work back to the reader.
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
6
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
7
|
import {
|
|
8
8
|
ENV_EXAMPLE_PATH,
|
|
9
9
|
ERROR_DOCS_URL,
|
|
10
10
|
tryResolveEnvironment,
|
|
11
11
|
usesDevCursorSecret,
|
|
12
12
|
} from '@ultimat3/core';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
checkDb,
|
|
15
|
+
createPostgresClient,
|
|
16
|
+
PGLITE_FIX,
|
|
17
|
+
PGLITE_MISSING,
|
|
18
|
+
PGLITE_PACKAGE,
|
|
19
|
+
} from '@ultimat3/db';
|
|
14
20
|
import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
|
|
15
21
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
16
22
|
import type { CliCommand, CommandContext } from './command';
|
|
@@ -61,6 +67,16 @@ export interface DoctorProbe {
|
|
|
61
67
|
* correctly answered `X_DB_UNAVAILABLE` (#F5).
|
|
62
68
|
*/
|
|
63
69
|
database(): Promise<Finding | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Is the EMBEDDED database usable, and is it the one this environment would open? A FACT and not
|
|
72
|
+
* a finding, the split `offlineFallback` makes below: reaching a module resolver is IO, and what
|
|
73
|
+
* an unresolvable optional peer MEANS is a pure rule with a pure test.
|
|
74
|
+
*
|
|
75
|
+
* `database()` answers `null` the moment `DATABASE_URL` is unset — which is exactly a bare VM,
|
|
76
|
+
* the configuration `x dev` invents a database FOR — so until this existed `x doctor` was silent
|
|
77
|
+
* about the only database a fresh box has. `bin/setup` found out instead, at `x db migrate`.
|
|
78
|
+
*/
|
|
79
|
+
embeddedDatabase(): Promise<EmbeddedDatabase>;
|
|
64
80
|
drift(): Promise<readonly Finding[]>;
|
|
65
81
|
/**
|
|
66
82
|
* The other half of the migrations directory: a newest migration with no `.snapshot.json`, which
|
|
@@ -76,11 +92,40 @@ export interface DoctorProbe {
|
|
|
76
92
|
offlineFallback(): Promise<OfflineFallbackFact>;
|
|
77
93
|
}
|
|
78
94
|
|
|
95
|
+
/** What `x doctor` reads about the embedded database, without opening it. */
|
|
96
|
+
export interface EmbeddedDatabase {
|
|
97
|
+
/**
|
|
98
|
+
* True while `DATABASE_URL` is unset or blank — the condition that makes PGlite the app's
|
|
99
|
+
* database (`resolveServices`), and the same reading `probeDatabase` returns `null` on.
|
|
100
|
+
*/
|
|
101
|
+
readonly selected: boolean;
|
|
102
|
+
/** Does `@electric-sql/pglite` resolve from the app root? */
|
|
103
|
+
readonly resolved: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
const finding = (code: string, cause: string, fix: string, at?: string): Finding =>
|
|
80
107
|
at === undefined
|
|
81
108
|
? { code, cause, fix, docs: ERROR_DOCS_URL }
|
|
82
109
|
: { code, cause, fix, docs: ERROR_DOCS_URL, at };
|
|
83
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The rule, pure. Red only where the embedded database is the one that would be opened: an app
|
|
113
|
+
* pointed at a real Postgres never loads PGlite, and a finding about an absent optional peer there
|
|
114
|
+
* is noise the reader learns to skim.
|
|
115
|
+
*
|
|
116
|
+
* `@ultimat3/db`'s own refusal, not a CLI twin of it — same `X_DB_UNAVAILABLE`, same sentence,
|
|
117
|
+
* same runnable fix. The package already says this the moment a query arrives; `x doctor` is what
|
|
118
|
+
* says it before `bin/setup` gets that far.
|
|
119
|
+
*/
|
|
120
|
+
export const embeddedDatabaseFinding = (fact: EmbeddedDatabase): Finding | undefined =>
|
|
121
|
+
fact.selected && !fact.resolved
|
|
122
|
+
? finding(
|
|
123
|
+
'X_DB_UNAVAILABLE',
|
|
124
|
+
`${PGLITE_MISSING} — and DATABASE_URL is unset or blank, so the embedded one is the database this app would open`,
|
|
125
|
+
PGLITE_FIX,
|
|
126
|
+
)
|
|
127
|
+
: undefined;
|
|
128
|
+
|
|
84
129
|
/** The file `x doctor` reports missing, and the one the reader creates. */
|
|
85
130
|
export const ENV_DEVELOPMENT = '.env.development';
|
|
86
131
|
|
|
@@ -229,6 +274,10 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
229
274
|
if (offline !== undefined) findings.push(offline);
|
|
230
275
|
const database = await probe.database();
|
|
231
276
|
if (database !== null) findings.push(database);
|
|
277
|
+
// The other half of the same question, and the half a bare VM lands on: `database()` is silent
|
|
278
|
+
// where there is no `DATABASE_URL`, and that silence IS the bare-VM configuration.
|
|
279
|
+
const embedded = embeddedDatabaseFinding(await probe.embeddedDatabase());
|
|
280
|
+
if (embedded !== undefined) findings.push(embedded);
|
|
232
281
|
findings.push(...(await probe.drift()));
|
|
233
282
|
// Last, and it is why `X_CLI_UNEXPECTED`'s `fix: x doctor --json` is not a dead end on the path an
|
|
234
283
|
// author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
|
|
@@ -249,6 +298,45 @@ export const doctorPort = (args: ParsedArgs): number =>
|
|
|
249
298
|
DEFAULT_DOCTOR_PORT,
|
|
250
299
|
);
|
|
251
300
|
|
|
301
|
+
/**
|
|
302
|
+
* `DATABASE_URL` as BOTH halves of the database question read it: unset and blank are one case.
|
|
303
|
+
* One seam, because the two halves are complementary — a second reading of the same variable is
|
|
304
|
+
* how a configuration ends up reported by neither probe, or by both.
|
|
305
|
+
*/
|
|
306
|
+
const externalUrl = (raw: string | undefined): string | undefined =>
|
|
307
|
+
raw === undefined || raw.trim() === '' ? undefined : raw;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Is the optional peer INSTALLED for this app — a `node_modules` walk up from the app root.
|
|
311
|
+
*
|
|
312
|
+
* Never an IMPORT: loading PGlite boots 26 MB of WASM and takes the single-writer lock the next
|
|
313
|
+
* command needs, and a diagnostic must not be the reason `x dev` cannot open the database it just
|
|
314
|
+
* reported on.
|
|
315
|
+
*
|
|
316
|
+
* Never `Bun.resolveSync` either, which was this check's first draft. It falls back to Bun's
|
|
317
|
+
* machine-global install cache: measured 2026-09-11 against a freshly scaffolded app with no
|
|
318
|
+
* `node_modules` anywhere above it, `Bun.resolveSync('@electric-sql/pglite', <app>)` answered
|
|
319
|
+
* `~/.bun/install/cache/@electric-sql/pglite@0.5.8@@@1/dist/index.js` — a version the app does not
|
|
320
|
+
* depend on, in a directory `bun install` never wrote for it. A box that had downloaded the package
|
|
321
|
+
* ONCE, for anything, would have read as ready on every app after it, which is the exact opposite
|
|
322
|
+
* of the question. The walk asks what `bun add` answers, and nothing else.
|
|
323
|
+
*
|
|
324
|
+
* Up from the root and not at it, because an app checked out inside a larger workspace is installed
|
|
325
|
+
* by the hoisting one: `examples/dummy` in this repository has no `node_modules` of its own.
|
|
326
|
+
*/
|
|
327
|
+
const installedAbove = (dir: string, specifier: string): boolean => {
|
|
328
|
+
let current = resolve(dir);
|
|
329
|
+
// Bounded by the path itself — one segment per step — rather than by `for (;;)` and the promise
|
|
330
|
+
// that `dirname('/')` is `/`. The loop still stops on that, one step earlier.
|
|
331
|
+
for (let remaining = current.split(sep).length; remaining > 0; remaining -= 1) {
|
|
332
|
+
if (existsSync(join(current, 'node_modules', specifier, 'package.json'))) return true;
|
|
333
|
+
const parent = dirname(current);
|
|
334
|
+
if (parent === current) return false;
|
|
335
|
+
current = parent;
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
};
|
|
339
|
+
|
|
252
340
|
/**
|
|
253
341
|
* A real `select 1` through the app's own driver, not a TCP connect: a running Postgres with the
|
|
254
342
|
* wrong credentials or a database that does not exist accepts the socket and refuses the session,
|
|
@@ -258,7 +346,7 @@ export const doctorPort = (args: ParsedArgs): number =>
|
|
|
258
346
|
* next `x db migrate` cannot have.
|
|
259
347
|
*/
|
|
260
348
|
async function probeDatabase(url: string | undefined): Promise<Finding | null> {
|
|
261
|
-
if (url === undefined
|
|
349
|
+
if (externalUrl(url) === undefined) return null;
|
|
262
350
|
const client = createPostgresClient({ url, applicationName: 'x-doctor' });
|
|
263
351
|
try {
|
|
264
352
|
const report = await checkDb(client);
|
|
@@ -296,6 +384,12 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
|
|
|
296
384
|
exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
|
|
297
385
|
portFree,
|
|
298
386
|
database: () => probeDatabase(process.env['DATABASE_URL']),
|
|
387
|
+
// `root ?? cwd` because the walk needs a directory that exists; outside an app `runDoctor`
|
|
388
|
+
// returns on `X_NOT_IN_APP` before this is ever asked, so the value only has to be honest.
|
|
389
|
+
embeddedDatabase: async () => ({
|
|
390
|
+
selected: externalUrl(process.env['DATABASE_URL']) === undefined,
|
|
391
|
+
resolved: installedAbove(root ?? cwd, PGLITE_PACKAGE),
|
|
392
|
+
}),
|
|
299
393
|
drift: async () => (root === undefined ? [] : checkMigrationDrift(root)),
|
|
300
394
|
snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
|
|
301
395
|
// `routes: undefined` outside an app is "not judged", which is what the caller already is:
|
package/src/e2e-driver.ts
CHANGED
|
@@ -70,7 +70,8 @@ export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixture
|
|
|
70
70
|
* declaration `driverFixtures()` registered — the ordinary way a driver arrives, last registration
|
|
71
71
|
* wins — so every `test('…', async ({ page }) => …)` in the suite gets a browser. `useE2eDriver`
|
|
72
72
|
* is the other half: it is what makes `hasE2eDriver()` answer true and stops `e2eTest` becoming a
|
|
73
|
-
* `test.skip`
|
|
73
|
+
* `test.skip` — which the gate now reports as a SKIPPED step rather than the green check it
|
|
74
|
+
* printed until #434, and which a repo whose `x.verify.json` names `e2e` gets red for.
|
|
74
75
|
*
|
|
75
76
|
* `budget`, `signIn` and `deploy` are deliberately NOT registered here. Each needs something a
|
|
76
77
|
* page cannot supply — byte counts off a built `dist/`, an app's own sign-in route, a second build
|
package/src/index.ts
CHANGED
|
@@ -67,8 +67,14 @@ export type { DeployPlan } from './cmd-deploy';
|
|
|
67
67
|
export { deployCommand, planDeploy } from './cmd-deploy';
|
|
68
68
|
export type { DevServer, StartDevOptions } from './cmd-dev';
|
|
69
69
|
export { devCommand, startDev } from './cmd-dev';
|
|
70
|
-
export type { DoctorProbe } from './cmd-doctor';
|
|
71
|
-
export {
|
|
70
|
+
export type { DoctorProbe, EmbeddedDatabase } from './cmd-doctor';
|
|
71
|
+
export {
|
|
72
|
+
doctorCommand,
|
|
73
|
+
ENV_DEVELOPMENT,
|
|
74
|
+
embeddedDatabaseFinding,
|
|
75
|
+
probeFor,
|
|
76
|
+
runDoctor,
|
|
77
|
+
} from './cmd-doctor';
|
|
72
78
|
export { ERRORS_SUBCOMMANDS, errorsCommand } from './cmd-errors';
|
|
73
79
|
export { FIX_SUBCOMMANDS, fixCommand } from './cmd-fix';
|
|
74
80
|
export type { GenerateOptions, Generator } from './cmd-generate';
|
package/src/messages.ts
CHANGED
|
@@ -279,6 +279,14 @@ const CATALOG = {
|
|
|
279
279
|
// The `errors` step's own coverage, in `output`: a scan without a parser reads most fix lines
|
|
280
280
|
// and not all of them, and a step that reports findings alone claims a completeness it lacks.
|
|
281
281
|
'cli.verify.fixCoverage': 'checked {checked} fix line(s), could not read {unreadable}',
|
|
282
|
+
// Why a skipped step is a dash when the step can say so: the suite exists and not one of its
|
|
283
|
+
// tests ran. Each test's own name carries the reason it skipped, and `bun test` prints only the
|
|
284
|
+
// counts — so the line says how many and `x test <type>` prints the names.
|
|
285
|
+
'cli.verify.allSkipped': 'found {skipped} test(s) and every one skipped itself',
|
|
286
|
+
// The other shape the same counts can take: a suite that reported no test at all, skipped ones
|
|
287
|
+
// included. Its own line and not `allSkipped` with a zero, because the repair differs — there is
|
|
288
|
+
// no skipped test to read a reason off, so the files the step selected hold no test.
|
|
289
|
+
'cli.verify.ranNothing': 'found no test to run in the file(s) it selected',
|
|
282
290
|
'cli.verify.serial': 'serial',
|
|
283
291
|
'cli.verify.workers': '{workers} workers',
|
|
284
292
|
'cli.env.checked': '{count} declared variable(s), all present and valid',
|
package/src/output.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { ERROR_DOCS_URL, renderThrowable, singleLine, stringField } from '@ultimat3/core';
|
|
6
6
|
import { msg } from './messages';
|
|
7
|
+
import type { TestCounts } from './test-counts';
|
|
7
8
|
|
|
8
9
|
export interface Finding {
|
|
9
10
|
readonly code: string;
|
|
@@ -24,6 +25,13 @@ export interface StepResult {
|
|
|
24
25
|
readonly output?: string;
|
|
25
26
|
/** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
|
|
26
27
|
readonly workers?: number;
|
|
28
|
+
/**
|
|
29
|
+
* What the step's suite executed. Absent for a step that spawned no test process, which is NOT
|
|
30
|
+
* the same state as `{ ran: 0 }` — a step with no suite and a suite whose every test skipped
|
|
31
|
+
* itself both report `skipped`, and this is the only thing that tells a reader which one it is
|
|
32
|
+
* looking at (#434).
|
|
33
|
+
*/
|
|
34
|
+
readonly tests?: TestCounts;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
export type JsonValue =
|
|
@@ -164,10 +172,27 @@ const width = (step: StepResult): string => {
|
|
|
164
172
|
return ` ${step.workers === 1 ? msg('cli.verify.serial') : msg('cli.verify.workers', { workers: step.workers })}`;
|
|
165
173
|
};
|
|
166
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Why a step is a dash, when the step itself can say. `- roadmap` is "there is nothing here to
|
|
177
|
+
* check"; a step that spawned a suite and executed none of it is a different state, which is what
|
|
178
|
+
* issue #434 reported as a green check — so a step carrying counts always says which it is, in one
|
|
179
|
+
* of the two shapes its counts can take. The reason each test skipped lives in that test's own
|
|
180
|
+
* NAME — `bun test` prints only the counts — so the line points at the suite and `x test <type>`
|
|
181
|
+
* is what prints the names.
|
|
182
|
+
*/
|
|
183
|
+
const why = (step: StepResult): string => {
|
|
184
|
+
const tests = step.tests;
|
|
185
|
+
if (step.skipped !== true || tests === undefined) return '';
|
|
186
|
+
if (tests.skipped === 0) return ` ${msg('cli.verify.ranNothing')}`;
|
|
187
|
+
return ` ${msg('cli.verify.allSkipped', { skipped: tests.skipped })}`;
|
|
188
|
+
};
|
|
189
|
+
|
|
167
190
|
export function renderHuman(result: CommandResult, verbose = false): string {
|
|
168
191
|
const out: string[] = [];
|
|
169
192
|
for (const step of result.steps ?? []) {
|
|
170
|
-
out.push(
|
|
193
|
+
out.push(
|
|
194
|
+
` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}${why(step)}`,
|
|
195
|
+
);
|
|
171
196
|
for (const finding of step.findings) out.push(renderFinding(finding, ' '));
|
|
172
197
|
// NOT escaped, and that is the one exception: `output` is this process's own captured
|
|
173
198
|
// subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
|
|
@@ -196,6 +221,9 @@ export function renderJson(result: CommandResult): string {
|
|
|
196
221
|
skipped: step.skipped === true,
|
|
197
222
|
findings: step.findings,
|
|
198
223
|
...(step.workers === undefined ? {} : { workers: step.workers }),
|
|
224
|
+
// The counts the human line's `why()` renders, as numbers: a `--json` reader deciding whether
|
|
225
|
+
// a skipped lane is missing a suite or missing a prerequisite needs the same fact CI's log has.
|
|
226
|
+
...(step.tests === undefined ? {} : { tests: step.tests }),
|
|
199
227
|
// A FAILED step carries its captured stdout, exactly as the human renderer prints it. CI runs
|
|
200
228
|
// `--json`, and without this the log said only "one or more unit tests failed" with a generic
|
|
201
229
|
// fix line — the failing test's name and its assertion diff existed and were thrown away, so
|
package/src/prerender.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
SW_REGISTER_PATH,
|
|
29
29
|
serviceWorkerArtifacts,
|
|
30
30
|
serviceWorkerHead,
|
|
31
|
+
serviceWorkerRegistration,
|
|
31
32
|
} from './sw-artifacts';
|
|
32
33
|
|
|
33
34
|
// Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
|
|
@@ -194,6 +195,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
194
195
|
// header). `serviceWorkerHead` is the one predicate behind both, so a page can never name a
|
|
195
196
|
// script the export does not carry.
|
|
196
197
|
const swHead = pwa === undefined ? undefined : serviceWorkerHead(pwa);
|
|
198
|
+
// The registration's BYTES now too, and not beside `sw.js` at the end. Every document below
|
|
199
|
+
// names this file and `measureDocumentJs` weighs it off disk, so writing it last made the
|
|
200
|
+
// measurement read whatever happened to be there: nothing on a clean `out` (recorded as 0) and
|
|
201
|
+
// the PREVIOUS build's copy on a reused one. That is the run-order dependence the `jsBytes`
|
|
202
|
+
// split removed, and it came straight back in `frameworkJsBytes` because the field changed and
|
|
203
|
+
// the ORDER did not. `serviceWorkerRegistration()` depends on two constants and no document, so
|
|
204
|
+
// it has nothing to wait for; `sw.js` still comes last, because its precache manifest really is
|
|
205
|
+
// built from the hashes of pages that do not exist yet.
|
|
206
|
+
if (swHead !== undefined) {
|
|
207
|
+
await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorkerRegistration());
|
|
208
|
+
}
|
|
197
209
|
if (pwa !== undefined) {
|
|
198
210
|
await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
|
|
199
211
|
// And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
|
|
@@ -252,6 +264,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
252
264
|
routes.push({
|
|
253
265
|
path: entry.path,
|
|
254
266
|
jsBytes: measured.jsBytes,
|
|
267
|
+
frameworkJsBytes: measured.frameworkBytes,
|
|
255
268
|
...(chain === undefined ? {} : { heaviestChain: chain }),
|
|
256
269
|
});
|
|
257
270
|
} catch (error) {
|
|
@@ -314,6 +327,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
314
327
|
heaviest = {
|
|
315
328
|
path: entry.path,
|
|
316
329
|
jsBytes: measured.jsBytes,
|
|
330
|
+
frameworkJsBytes: measured.frameworkBytes,
|
|
317
331
|
...(chain === undefined ? {} : { heaviestChain: chain }),
|
|
318
332
|
};
|
|
319
333
|
}
|
|
@@ -334,9 +348,10 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
334
348
|
styles,
|
|
335
349
|
documents,
|
|
336
350
|
});
|
|
351
|
+
// `sw.js` only: `serviceWorker.register` IS `serviceWorkerRegistration()`, already on disk above
|
|
352
|
+
// and identical by construction. A second writer of one path is how the two could ever disagree.
|
|
337
353
|
if (serviceWorker !== undefined) {
|
|
338
354
|
await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
|
|
339
|
-
await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
|
|
340
355
|
}
|
|
341
356
|
const stats = await writeBuildStats(options.root, { routes });
|
|
342
357
|
// Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
|
package/src/sw-artifacts.ts
CHANGED
|
@@ -129,8 +129,15 @@ const staticAssets = (islands: IslandBundle, styles: StyleBundle): readonly Prec
|
|
|
129
129
|
* silently controls a subdirectory. The `catch` because a registration that throws in a browser
|
|
130
130
|
* with service workers disabled — an incognito profile, an enterprise policy — must not take an
|
|
131
131
|
* otherwise working page down with it.
|
|
132
|
+
*
|
|
133
|
+
* EXPORTED because a static export has to put these bytes on disk BEFORE it renders, not after.
|
|
134
|
+
* The file is named by every document and weighed by `measureDocumentJs`, and it used to be
|
|
135
|
+
* written last, beside `sw.js` — so the measurement read a file that did not exist yet on a clean
|
|
136
|
+
* output directory, and the PREVIOUS build's copy on a reused one. `sw.js` still comes last, for
|
|
137
|
+
* the reason its own comment gives (its precache manifest is built from the rendered documents'
|
|
138
|
+
* content hashes); this half depends on nothing but two constants, so it can and must come first.
|
|
132
139
|
*/
|
|
133
|
-
const
|
|
140
|
+
export const serviceWorkerRegistration = (): string =>
|
|
134
141
|
`if ('serviceWorker' in navigator) {
|
|
135
142
|
addEventListener('load', function () {
|
|
136
143
|
navigator.serviceWorker
|
|
@@ -199,7 +206,7 @@ export function serviceWorkerArtifacts(
|
|
|
199
206
|
);
|
|
200
207
|
return {
|
|
201
208
|
source: output.source,
|
|
202
|
-
register:
|
|
209
|
+
register: serviceWorkerRegistration(),
|
|
203
210
|
head,
|
|
204
211
|
precache: output.precache,
|
|
205
212
|
// `output.warnings` IS `output.precache.warnings` — the generator returns the manifest's list
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The CI half of what `x new` writes: one workflow that runs the app's OWN two commands.
|
|
2
|
+
//
|
|
3
|
+
// Nothing under `templates/` emitted `.github` until this file, so every scaffolded app started
|
|
4
|
+
// life with a gate that ran on exactly one machine — the author's. The two commands are `bin/setup`
|
|
5
|
+
// and `bin/check` and NOT a restatement of their steps: a workflow that spelled out `bun install`,
|
|
6
|
+
// `x db migrate`, `x build` and `x verify` is a second definition of the gate, free to drift from
|
|
7
|
+
// the scripts a human runs, and axiom 1 has one way to do each thing. `bin/check` is also not a
|
|
8
|
+
// synonym for `x verify` — it BUILDS first, and the build is what makes the gate's `budgets` step
|
|
9
|
+
// measurable rather than X_BUDGET_UNMEASURED.
|
|
10
|
+
|
|
11
|
+
import { REQUIRED_BUN } from '../../app-root';
|
|
12
|
+
import type { GeneratedFile, NameSet } from '../naming';
|
|
13
|
+
|
|
14
|
+
/** Where the workflow lands. GitHub reads this path and no other. */
|
|
15
|
+
export const CI_WORKFLOW_PATH = '.github/workflows/ci.yml';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pinned by commit SHA with the version in the trailing comment, because this step runs before any
|
|
19
|
+
* code of yours does and `@v2` is a tag its owner can move. `actions/*` follows its major tag
|
|
20
|
+
* instead: GitHub owns the runner and the tag together. Same rule the framework's own
|
|
21
|
+
* `.github/actions/setup/action.yml` states, and the same SHA.
|
|
22
|
+
*/
|
|
23
|
+
const SETUP_BUN = 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0';
|
|
24
|
+
|
|
25
|
+
const ci = (app: NameSet): string => `name: ci
|
|
26
|
+
|
|
27
|
+
# The gate for ${app.kebab}: the same two commands \`README.md\` tells a human to run, in the same
|
|
28
|
+
# order, on a machine that has never seen this repository. A check that exists only in CI is one
|
|
29
|
+
# nobody can reproduce locally, and a CI file that restates the gate's steps is a second gate.
|
|
30
|
+
#
|
|
31
|
+
# \`bin/check\` is \`x build --target static\` and THEN \`x verify\`. The build is not a convenience:
|
|
32
|
+
# the gate's \`budgets\` step compares declared limits against measured bytes in
|
|
33
|
+
# \`.x/build-stats.json\`, so a run with no build reports X_BUDGET_UNMEASURED. Run \`x verify\` here
|
|
34
|
+
# instead and this workflow is red for a reason that has nothing to do with the commit.
|
|
35
|
+
#
|
|
36
|
+
# No \`services:\` block, deliberately — \`bin/setup\` brings up embedded Postgres in-process, so
|
|
37
|
+
# there is nothing to provision and nothing to wait on. Add one the day this app needs a real
|
|
38
|
+
# server, beside the \`DATABASE_URL\` that selects it.
|
|
39
|
+
|
|
40
|
+
# EVERY push, not just the default branch's. \`x new\` runs a plain \`git init\` and takes whatever
|
|
41
|
+
# \`init.defaultBranch\` this machine already agreed on, so a \`branches: [main]\` filter silently
|
|
42
|
+
# runs nothing on a repository whose branch is called something else — and \`CLAUDE.md\` promises a
|
|
43
|
+
# run on every push, which a filter would make untrue for the app that read it.
|
|
44
|
+
on:
|
|
45
|
+
push:
|
|
46
|
+
pull_request:
|
|
47
|
+
|
|
48
|
+
permissions:
|
|
49
|
+
contents: read
|
|
50
|
+
|
|
51
|
+
jobs:
|
|
52
|
+
check:
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
# A bound on a HANG, not on cost: a step that never returns holds a runner until GitHub's
|
|
55
|
+
# six-hour default expires, and the failure is invisible for all six of them.
|
|
56
|
+
timeout-minutes: 20
|
|
57
|
+
steps:
|
|
58
|
+
- uses: actions/checkout@v7
|
|
59
|
+
- uses: ${SETUP_BUN}
|
|
60
|
+
with:
|
|
61
|
+
# \`package.json\`'s \`engines.bun\` floor, exactly — the OLDEST runtime this app declares
|
|
62
|
+
# it supports, so a call that needs a newer Bun fails here rather than in the install of
|
|
63
|
+
# whoever took the app at its word. Never \`latest\`: a Bun minor landing unannounced is a
|
|
64
|
+
# runtime change nobody chose.
|
|
65
|
+
bun-version: '${REQUIRED_BUN}'
|
|
66
|
+
# Two steps rather than one \`&&\`, so the log names which half failed and times each.
|
|
67
|
+
- run: bin/setup
|
|
68
|
+
- run: bin/check
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
/** The CI a new app is born with, in the order a reader meets it. */
|
|
72
|
+
export function githubFiles(app: NameSet): readonly GeneratedFile[] {
|
|
73
|
+
return [{ path: CI_WORKFLOW_PATH, contents: ci(app) }];
|
|
74
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
7
|
|
|
8
8
|
const feature = (app: NameSet): string => `---
|
|
9
|
-
description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`
|
|
9
|
+
description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`bin/check\`.
|
|
10
10
|
argument-hint: <what you want built or fixed, plain language>
|
|
11
11
|
allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
|
|
12
12
|
---
|
|
@@ -16,7 +16,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
|
|
|
16
16
|
You are a senior engineer on **${app.kebab}**, an Ultimate app. Read \`AGENTS.md\` before designing
|
|
17
17
|
anything — it is the short form of every rule below, and it wins where the two disagree.
|
|
18
18
|
|
|
19
|
-
**Done means \`
|
|
19
|
+
**Done means \`bin/check\` green.** A passing unit test is not done. A working \`x dev\` is not done.
|
|
20
20
|
Report what you actually ran, never what you assume passed.
|
|
21
21
|
|
|
22
22
|
## Request
|
|
@@ -77,10 +77,16 @@ the filename never is. One interactive control on a 0kb page is an island: \`x g
|
|
|
77
77
|
## 4. Gate it
|
|
78
78
|
|
|
79
79
|
\`\`\`sh
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
bin/check # the gate. green = shippable
|
|
81
|
+
bin/check --json # the same, machine-readable
|
|
82
82
|
\`\`\`
|
|
83
83
|
|
|
84
|
+
\`bin/check\` is \`x build --target static\` and THEN \`x verify\`, and the order is the whole point:
|
|
85
|
+
the \`budgets\` step measures \`.x/build-stats.json\`, which only a build writes, so \`x verify\` on
|
|
86
|
+
its own reports X_BUDGET_UNMEASURED on a tree that is fine. \`--json\` is forwarded to both halves;
|
|
87
|
+
a machine consumer takes the last line. CI runs this same script — \`.github/workflows/ci.yml\` is
|
|
88
|
+
\`bin/setup\` and then \`bin/check\`, nothing else.
|
|
89
|
+
|
|
84
90
|
Red is instructions, not a verdict: **every finding carries an executable \`fix:\` — run it verbatim
|
|
85
91
|
before improvising**, and \`x errors explain <CODE>\` expands any code it names. Never narrow the gate
|
|
86
92
|
to make it pass: there is no \`--only\` and no \`--skip\`, on purpose, and disabling a lint rule or
|
|
@@ -112,7 +118,7 @@ ISO code, never a float. Bun only.
|
|
|
112
118
|
Primitive: <which of the eight> Slice: <dir>
|
|
113
119
|
Generated: <the x g invocations you ran>
|
|
114
120
|
Changed: <files>
|
|
115
|
-
Gate:
|
|
121
|
+
Gate: bin/check ✓ | ✗ <failing steps>
|
|
116
122
|
Deferred: <what you did not do, and why> [never omit this line]
|
|
117
123
|
\`\`\`
|
|
118
124
|
`;
|
|
@@ -166,7 +172,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
|
|
|
166
172
|
- What to add, next to the source as \`<file>.test.ts\`. Command to run it.
|
|
167
173
|
|
|
168
174
|
## Done when
|
|
169
|
-
- Acceptance criteria, ending in \`
|
|
175
|
+
- Acceptance criteria, ending in \`bin/check\` green.
|
|
170
176
|
|
|
171
177
|
## Risks
|
|
172
178
|
- Anything the executor must decide, and every claim in the ask the code disproves.
|
|
@@ -179,7 +185,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
|
|
|
179
185
|
- No checkboxes. The plan is a map, not a tracker.
|
|
180
186
|
- The plan must obey the app's own rules — one way to do each thing, generators over hand-written
|
|
181
187
|
files, imports that never cross a surface boundary, a stable error code with a runnable \`fix:\` for
|
|
182
|
-
every new failure, and \`
|
|
188
|
+
every new failure, and \`bin/check\` green as the last line of *Done when*.
|
|
183
189
|
|
|
184
190
|
## Output
|
|
185
191
|
|
|
@@ -196,13 +202,18 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
|
|
196
202
|
|
|
197
203
|
# /verify
|
|
198
204
|
|
|
199
|
-
Run \`
|
|
205
|
+
Run \`bin/check\`.
|
|
206
|
+
|
|
207
|
+
It is \`x build --target static\` and then \`x verify\`, and it is THE gate — the one CI runs
|
|
208
|
+
(\`.github/workflows/ci.yml\`) and the one \`README.md\` names. \`x verify\` alone is half of it:
|
|
209
|
+
the \`budgets\` step measures \`.x/build-stats.json\`, which only the build writes, so a bare
|
|
210
|
+
\`x verify\` reports X_BUDGET_UNMEASURED on a tree with nothing wrong with it.
|
|
200
211
|
|
|
201
212
|
Green: say so and stop.
|
|
202
213
|
|
|
203
214
|
Red: fix every finding, then re-run until green. Each finding carries a stable code, a cause and an
|
|
204
215
|
executable \`fix:\` — **run the \`fix:\` verbatim before improvising**, and use \`x errors explain <CODE>\`
|
|
205
|
-
when the cause is not enough. \`
|
|
216
|
+
when the cause is not enough. \`bin/check --json\` gives the same steps machine-readably; \`x doctor\`
|
|
206
217
|
covers the case where the environment, not the code, is what is broken.
|
|
207
218
|
|
|
208
219
|
Do not narrow the gate to make it pass. There is no \`--only\` and no \`--skip\`; disabling a lint rule,
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// the bin/ shims and the optional dev compose. Separated from the config half so neither file has
|
|
3
3
|
// to be scrolled to find the other — one file, one job applies to templates too. The image, its
|
|
4
4
|
// ignore file, the production topology and the deploy page are `scaffold-container.ts`; the
|
|
5
|
-
// `.claude/` harness that reads AGENTS.md is `scaffold-claude.ts
|
|
5
|
+
// `.claude/` harness that reads AGENTS.md is `scaffold-claude.ts`; the one workflow is
|
|
6
|
+
// `github/ci.yml.ts`.
|
|
6
7
|
|
|
7
8
|
import { LINE_CEILING } from '../workspace-checks';
|
|
9
|
+
import { githubFiles } from './github/ci.yml';
|
|
8
10
|
import type { GeneratedFile, NameSet } from './naming';
|
|
9
11
|
import { claudeFiles } from './scaffold-claude';
|
|
10
12
|
import { containerFiles } from './scaffold-container';
|
|
@@ -19,7 +21,7 @@ not exist — five of these had an empty column and were each measured green on
|
|
|
19
21
|
|
|
20
22
|
| Rule | Detail | Refused by |
|
|
21
23
|
|---|---|---|
|
|
22
|
-
| One gate | \`x
|
|
24
|
+
| One gate | \`bin/check\` — \`x build\` and then \`x verify\`. Green means shippable; never merge red. | the gate itself, and \`.github/workflows/ci.yml\` on every push and pull request |
|
|
23
25
|
| One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` | review |
|
|
24
26
|
| Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf | \`X_BOUNDARY_SITE_TO_APP\` |
|
|
25
27
|
| Data | routes call actions and queries; only \`repo.ts\` touches the database | \`X_BOUNDARY_ROUTE_TO_DB\` |
|
|
@@ -54,7 +56,19 @@ see, and the type already fires — measured, \`price: 19.99\` in a seed is
|
|
|
54
56
|
\`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
|
|
55
57
|
it would be worse than the type that really does.
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
\`bin/check\` is the gate and \`x verify\` is only its second half: the first is
|
|
60
|
+
\`x build --target static\`, and the build is what writes the \`.x/build-stats.json\` the
|
|
61
|
+
\`budgets\` step measures. Run \`x verify\` on a tree nobody has built and \`budgets\` is red with
|
|
62
|
+
X_BUDGET_UNMEASURED — the gate reporting on a file that does not exist, not on your code.
|
|
63
|
+
|
|
64
|
+
The platform's \`.dz/\` is ADDITIVE, in both directions. developerz.ai keeps its own files under
|
|
65
|
+
\`.dz/maintainer/\` and \`.dz/pipeline/\`; nothing \`x new\` writes lands under \`.dz/\` and nothing
|
|
66
|
+
here is generated from it. So the scaffold never clobbers a maintainer policy, the platform never
|
|
67
|
+
clobbers \`AGENTS.md\`, \`bin/\`, \`.claude/\` or \`.github/\`, and deleting either side leaves the
|
|
68
|
+
other exactly as it was.
|
|
69
|
+
|
|
70
|
+
Commands: \`bin/setup\`, \`bin/dev\`, \`bin/check\`, \`x g <primitive>\`, \`x g guard <name>\`,
|
|
71
|
+
\`x db branch create <name>\`, \`x doctor\`.
|
|
58
72
|
|
|
59
73
|
Project notes for ${app.kebab}: replace this line with the conventions a newcomer could not guess.
|
|
60
74
|
`;
|
|
@@ -63,7 +77,12 @@ const claude = (app: NameSet): string => `# CLAUDE.md
|
|
|
63
77
|
|
|
64
78
|
${app.kebab} — Ultimate app. Read AGENTS.md first; it is the same content in the same order.
|
|
65
79
|
|
|
66
|
-
- Gate: \`
|
|
80
|
+
- Gate: \`bin/check\` (add \`--json\` for machine output — it reaches both halves). It is
|
|
81
|
+
\`x build --target static\` and then \`x verify\`; \`x verify\` alone leaves \`budgets\` with
|
|
82
|
+
nothing to measure. \`.github/workflows/ci.yml\` runs \`bin/setup && bin/check\` on every push
|
|
83
|
+
and pull request, so CI and your terminal run the same two commands.
|
|
84
|
+
- \`.dz/\` belongs to the developerz.ai platform and is additive both ways: the scaffold writes
|
|
85
|
+
nothing there, and nothing there is generated from this repo. Neither side clobbers the other.
|
|
67
86
|
- Scaffold, do not hand-write: \`x g <kind> <name>\` — \`x g --help\` lists every kind, and is the
|
|
68
87
|
only place that list is stated.
|
|
69
88
|
- Destructive DB work goes in a branch: \`x db branch create <name>\`, never the shared dev DB.
|
|
@@ -83,7 +102,7 @@ Built with [Ultimate](https://github.com/developerz-ai/ultimate). Bun-only, Post
|
|
|
83
102
|
\`\`\`sh
|
|
84
103
|
bin/setup # prerequisites, deps, env, the first migration, migrate, seed, the manifest
|
|
85
104
|
bin/dev # all roles in one process, embedded Postgres, /_x mounted
|
|
86
|
-
bin/check # the gate: typecheck, lint, boundaries, tests, drift, budgets
|
|
105
|
+
bin/check # the gate: a static build, then typecheck, lint, boundaries, tests, drift, budgets
|
|
87
106
|
\`\`\`
|
|
88
107
|
|
|
89
108
|
\`packages/db/migrations\` starts empty and \`x db gen\` is its only writer — \`bin/setup\` runs
|
|
@@ -102,6 +121,7 @@ with \`X_DB_DRIFT\`, and that is the fix it names.
|
|
|
102
121
|
| \`packages/*\` | domain, db, i18n, ui, mcp |
|
|
103
122
|
| \`app.config.ts\` | the one config file |
|
|
104
123
|
| \`x.manifest.json\` | generated facts: routes, actions, jobs, policies |
|
|
124
|
+
| \`.github/workflows/ci.yml\` | \`bin/setup\` then \`bin/check\`, on push and pull request |
|
|
105
125
|
`;
|
|
106
126
|
|
|
107
127
|
const binSetup = (): string => `#!/usr/bin/env bash
|
|
@@ -204,6 +224,10 @@ export function docsFiles(app: NameSet): readonly GeneratedFile[] {
|
|
|
204
224
|
// The harness half of the same job AGENTS.md does. It lands in the app's own repo rather than
|
|
205
225
|
// in a global config, so it is visible in the scaffold's diff and deletable in one line.
|
|
206
226
|
...claudeFiles(app),
|
|
227
|
+
// The same two commands `README.md` opens with, run by a machine that has never seen this
|
|
228
|
+
// repository. Registered here and not in `scaffold-repo.ts` because it is documentation of the
|
|
229
|
+
// gate in executable form, which is the job this file has.
|
|
230
|
+
...githubFiles(app),
|
|
207
231
|
...containerFiles(app),
|
|
208
232
|
];
|
|
209
233
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// container files scaffold-container.ts.
|
|
6
6
|
|
|
7
7
|
import { ENV_EXAMPLE_PATH } from '@ultimat3/core';
|
|
8
|
+
import { REQUIRED_BUN } from '../app-root';
|
|
8
9
|
import { VERIFY_FLOOR_FILE } from '../verify-floor';
|
|
9
10
|
import type { VerifyStepName } from '../verify-step';
|
|
10
11
|
import type { GeneratedFile, NameSet } from './naming';
|
|
@@ -37,6 +38,14 @@ const BIOME_VERSION = '2.5.8';
|
|
|
37
38
|
*/
|
|
38
39
|
const TYPESCRIPT_VERSION = '^7.0.2';
|
|
39
40
|
|
|
41
|
+
// `engines.bun` is `REQUIRED_BUN`, the floor the SHIPPED `x` enforces
|
|
42
|
+
// (`packages/cli/src/app-root.ts`), and not a second literal. It was one: `>=1.3.0`, a whole minor
|
|
43
|
+
// BELOW the CLI the app then runs, so on a box with Bun 1.3.x `bun install` succeeded and the very
|
|
44
|
+
// next line of `bin/setup` died `X_BUN_VERSION: Bun 1.3.14 is older than the required 1.4.0` — a
|
|
45
|
+
// floor admitting a runtime the app's own first command refuses. Interpolated, the two cannot
|
|
46
|
+
// disagree; `scripts/bun-pin.test.ts` reads the emitted string as one more pin site, and
|
|
47
|
+
// `github/ci.yml.ts` pins the workflow's Bun to the same constant.
|
|
48
|
+
//
|
|
40
49
|
// `version` is not decoration: the manifest's app version IS the contract's compatibility gate,
|
|
41
50
|
// and the manifest never fabricates one — so an app scaffolded without it failed `x manifest`,
|
|
42
51
|
// the `manifest` verify step and every production boot with X_APP_PACKAGE_INVALID.
|
|
@@ -89,7 +98,7 @@ const rootPackage = (app: NameSet, version: string): string => `{
|
|
|
89
98
|
"solid-js": "1.9.14"
|
|
90
99
|
},
|
|
91
100
|
"engines": {
|
|
92
|
-
"bun": "
|
|
101
|
+
"bun": ">=${REQUIRED_BUN}"
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
`;
|
|
@@ -286,7 +295,9 @@ const biome = (): string => `{
|
|
|
286
295
|
*
|
|
287
296
|
* Two remain absent. `e2e` has a scaffolded file and it is an `e2eTest` — `test.skip` until the app
|
|
288
297
|
* registers a browser driver, so the step would run zero tests and fail the ratchet on the
|
|
289
|
-
* scaffold's own placeholder.
|
|
298
|
+
* scaffold's own placeholder. Its absence is visible rather than silent: a suite that executed
|
|
299
|
+
* nothing reports as a SKIPPED step, not the green check it printed until #434, so the app's own
|
|
300
|
+
* gate line says `- e2e` and names it in the summary until a driver makes it run. `contract-diff` needs a committed `x.manifest.json`, which
|
|
290
301
|
* `x manifest` writes later. Each joins the list in the commit that makes the app's gate run it.
|
|
291
302
|
*/
|
|
292
303
|
const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
|
package/src/verify-run.ts
CHANGED
|
@@ -53,20 +53,29 @@ export async function runVerify(
|
|
|
53
53
|
findings: [findingOf(error, step.name)],
|
|
54
54
|
}),
|
|
55
55
|
);
|
|
56
|
+
// A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
|
|
57
|
+
// over an all-skipped file, so the counts are the only channel that can tell the two apart.
|
|
58
|
+
// ONE definition of "nothing ran", read twice, because the floor decides which of the two
|
|
59
|
+
// things it means — exactly as it already does for a step whose `applies` said no.
|
|
60
|
+
const tests = outcome.tests;
|
|
61
|
+
const nothingRan = tests !== undefined && tests.ran === 0;
|
|
62
|
+
const required = floorRequires(floor, step.name);
|
|
56
63
|
// A step the floor requires whose suite executed nothing is the same vanished suite as a step
|
|
57
64
|
// with no files at all — the run just had to finish before it could be seen. Appended to the
|
|
58
65
|
// step's own findings so `data.failed`, the counts and every gate reading this table carry it.
|
|
59
|
-
const vanished =
|
|
60
|
-
floorRequires(floor, step.name) && outcome.tests !== undefined && outcome.tests.ran === 0
|
|
61
|
-
? [skippedSuiteFinding(step.name, outcome.tests.skipped)]
|
|
62
|
-
: [];
|
|
66
|
+
const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
|
|
63
67
|
results.push({
|
|
64
68
|
name: step.name,
|
|
65
69
|
ok: outcome.ok && vanished.length === 0,
|
|
66
70
|
durationMs: Math.round(performance.now() - started),
|
|
71
|
+
// Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
|
|
72
|
+
// the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
|
|
73
|
+
// step table may never do — a reader cannot tell a lane that ran from a lane that did not.
|
|
74
|
+
skipped: nothingRan && !required,
|
|
67
75
|
findings: [...outcome.findings, ...vanished],
|
|
68
76
|
...(outcome.output === undefined ? {} : { output: outcome.output }),
|
|
69
77
|
...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
|
|
78
|
+
...(tests === undefined ? {} : { tests }),
|
|
70
79
|
});
|
|
71
80
|
}
|
|
72
81
|
const failedSteps = results.filter((step) => !step.ok).map((step) => step.name);
|