@ultimat3/cli 7.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +24 -4
- package/README.md +8 -3
- package/package.json +26 -25
- package/src/app-boundaries.ts +55 -5
- package/src/app-load.ts +7 -0
- package/src/bin.ts +6 -3
- package/src/ci-log.ts +0 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +43 -6
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +55 -4
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +68 -6
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-verify.ts +47 -6
- package/src/dev-assets.ts +4 -7
- package/src/dev-cache.ts +130 -33
- package/src/dev-lock.ts +124 -12
- package/src/dev-purge.ts +120 -0
- package/src/dev-queue.ts +39 -9
- package/src/dev-render.ts +11 -14
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +137 -6
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +5 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -6
- package/src/island-styles.ts +1 -1
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +12 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/prerender.ts +2 -1
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/runtime-overrides.ts +12 -4
- package/src/serve.ts +1 -1
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +27 -4
- package/src/solid-loader.ts +1 -1
- package/src/style-csp.ts +2 -1
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +3 -0
- package/src/templates/island.ts +2 -1
- package/src/templates/route.ts +1 -1
- package/src/templates/scaffold-app.ts +3 -82
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +14 -6
- package/src/templates/scaffold-docs.ts +34 -16
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-repo.ts +40 -7
- package/src/test-select.ts +4 -3
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/write-line.ts +23 -5
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The two entry files a deploy needs — `apps/web/server.ts` and `apps/web/prerender.ts`. Split out
|
|
2
|
+
// of `scaffold-app.ts` because that file is the three SURFACES and these are neither: they are what
|
|
3
|
+
// a container starts and what a CDN is handed. Both are deliberately thin — which role a container
|
|
4
|
+
// is, which port it binds, how it drains and what a static build enumerates are the framework's
|
|
5
|
+
// answers, so an upgrade moves them without a codemod in every app that ever shipped.
|
|
6
|
+
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
const server =
|
|
10
|
+
(): string => `// The production entry. \`docker/Dockerfile\` starts this, and \`x build --target binary\` compiles it.
|
|
11
|
+
// ROLE selects what this process is — web, sync, worker, scheduler, replicator, or migrate, which
|
|
12
|
+
// applies the migrations and exits. PORT is bound on every interface, because a container bound to
|
|
13
|
+
// localhost is unreachable through its own port mapping.
|
|
14
|
+
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { runRole } from '@ultimat3/cli';
|
|
17
|
+
|
|
18
|
+
// MORE THAN ONE REPLICA? Add these two lines, above \`runRole\`:
|
|
19
|
+
//
|
|
20
|
+
// import { configureIdempotency } from '@ultimat3/action';
|
|
21
|
+
// configureIdempotency({ scope: 'shared' });
|
|
22
|
+
//
|
|
23
|
+
// \`idempotent: true\` on an action promises that a retry does not repeat the work. Under the
|
|
24
|
+
// process-scoped default that promise holds inside ONE process — a client retrying
|
|
25
|
+
// \`POST /api/payments/charge\` after a timeout lands on another replica, which has never seen the
|
|
26
|
+
// key, and charges the card twice, silently, with \`x verify\` green. Declaring \`'shared'\` is what
|
|
27
|
+
// makes that a boot error (\`X_IDEMPOTENCY_NOT_SHARED\`) unless a shared store is installed.
|
|
28
|
+
// \`runRole\` installs the Postgres one for you, on the connection it resolved from \`DATABASE_URL\`,
|
|
29
|
+
// so the declaration is all this app owes. It must run before \`runRole\` imports the actions.
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Where the app is. From this file normally — the image's WORKDIR is not the app root's business.
|
|
33
|
+
* A \`--compile\` binary is the exception: its \`import.meta.dir\` is Bun's virtual filesystem, which
|
|
34
|
+
* holds this module's bundled imports and none of the app's source, and the framework's registries
|
|
35
|
+
* are filled by scanning that source at boot. So a binary reads its root from the directory it is
|
|
36
|
+
* started in — it is a launcher for an app tree, not a self-contained copy of one.
|
|
37
|
+
*/
|
|
38
|
+
const root = import.meta.dir.startsWith('/$bunfs')
|
|
39
|
+
? process.cwd()
|
|
40
|
+
: join(import.meta.dir, '..', '..');
|
|
41
|
+
|
|
42
|
+
// Guarded, because the framework's module scan imports every file under apps/*/ to fill its
|
|
43
|
+
// registries — an unguarded boot would start a server inside \`x verify\`.
|
|
44
|
+
if (import.meta.main) {
|
|
45
|
+
await runRole({ root, env: Bun.env });
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const prerender =
|
|
50
|
+
(): string => `// The static entry. \`x build --target static\` runs this with \`--out <dir>\` and it writes one HTML
|
|
51
|
+
// file per \`render: 'static'\` route — a CDN or an object store then serves site/ with no process
|
|
52
|
+
// behind it. Every other render mode needs a running app and is reported as skipped, never emitted.
|
|
53
|
+
//
|
|
54
|
+
// Skipped is not unweighed: a route that declares a \`budget:\` is rendered in memory and measured
|
|
55
|
+
// whatever its mode, so \`x verify\`'s \`budgets\` step has a number for it. \`unmeasured\` is the list
|
|
56
|
+
// this build could not render — each one is an X_BUDGET_UNMEASURED at the gate, and this is where
|
|
57
|
+
// the reason is.
|
|
58
|
+
//
|
|
59
|
+
// It writes the whole of \`pages\` and \`skipped\`, never a COUNT of either. A count is what let a
|
|
60
|
+
// partial artifact read as a complete one: someone pointed a screenshot tool at \`.x/static\` and
|
|
61
|
+
// filed "the island did not mount" against a route that had never been emitted (issue #242). Each
|
|
62
|
+
// skipped route carries its own \`reason\` and \`why\`, and \`report\` is where the same inventory
|
|
63
|
+
// landed on disk — which is what \`x build --target static --json\` reads back.
|
|
64
|
+
|
|
65
|
+
import { join } from 'node:path';
|
|
66
|
+
import { DEFAULT_ORIGIN, type PrerenderReport, prerenderSite } from '@ultimat3/cli';
|
|
67
|
+
import { routeEntries } from '@ultimat3/render';
|
|
68
|
+
import { buildRobots, buildSitemap, type RouteRecord } from '@ultimat3/seo';
|
|
69
|
+
|
|
70
|
+
const root = join(import.meta.dir, '..', '..');
|
|
71
|
+
const flag = Bun.argv.indexOf('--out');
|
|
72
|
+
const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', 'static');
|
|
73
|
+
// SITE_ORIGIN is what canonical and og:url are built from; the default is only ever a local build.
|
|
74
|
+
// Property access, not \`Bun.env['SITE_ORIGIN']\`: the scaffolded tsconfig does not set
|
|
75
|
+
// \`noPropertyAccessFromIndexSignature\`, so the bracket form is the one biome's useLiteralKeys
|
|
76
|
+
// reports — a diagnostic in an app's first lint run over a file the app never wrote.
|
|
77
|
+
const origin = Bun.env.SITE_ORIGIN;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The route table as \`@ultimat3/seo\` reads it. \`RouteRecord\` is a static row and \`defineRoute\`
|
|
81
|
+
* is a live declaration, so one has to be projected onto the other — and \`prerenderSite\` has
|
|
82
|
+
* already loaded the app by the time this runs, which is what fills \`routeEntries()\`.
|
|
83
|
+
*
|
|
84
|
+
* A DYNAMIC route contributes exactly the URLs this build enumerated for it, read back off the
|
|
85
|
+
* report rather than by calling \`prerender()\` a second time: the sitemap then cannot name a page
|
|
86
|
+
* the artifact does not contain, which is the only failure mode a sitemap really has.
|
|
87
|
+
*/
|
|
88
|
+
const siteRoutes = (report: PrerenderReport): readonly RouteRecord[] =>
|
|
89
|
+
routeEntries()
|
|
90
|
+
.filter((entry) => entry.surface === 'site')
|
|
91
|
+
.map((entry) => ({
|
|
92
|
+
path: entry.path,
|
|
93
|
+
file: entry.file,
|
|
94
|
+
surface: 'site' as const,
|
|
95
|
+
render: entry.config.render,
|
|
96
|
+
prerender: () =>
|
|
97
|
+
report.pages.filter((page) => page.route === entry.path).map((page) => page.path),
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* \`sitemap.xml\` and \`robots.txt\`, into the same directory the HTML went. Both belong to the
|
|
102
|
+
* ARTIFACT rather than to a request: a static export is served with no process behind it, so a
|
|
103
|
+
* route that answered them at run time would be a file the CDN never has.
|
|
104
|
+
*
|
|
105
|
+
* \`buildRobots\` fails closed — anything that is not \`ULTIMATE_ENV=production\` emits
|
|
106
|
+
* \`Disallow: /\` and advertises no sitemap — so a preview build cannot outrank the real site.
|
|
107
|
+
*/
|
|
108
|
+
async function writeSeoFiles(report: PrerenderReport, baseUrl: string): Promise<readonly string[]> {
|
|
109
|
+
const sitemap = await buildSitemap(siteRoutes(report), { baseUrl });
|
|
110
|
+
// Past 50,000 URLs \`files\` are \`/sitemap-N.xml\` and the index is \`/sitemap.xml\`; below it,
|
|
111
|
+
// \`files\` is that one file and there is no index. Writing both lists covers each case once.
|
|
112
|
+
const written = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
|
|
113
|
+
for (const file of written) await Bun.write(join(out, file.path), file.xml);
|
|
114
|
+
await Bun.write(join(out, 'robots.txt'), buildRobots({ baseUrl, sitemaps: ['/sitemap.xml'] }));
|
|
115
|
+
return [...written.map((file) => file.path), '/robots.txt'];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (import.meta.main) {
|
|
119
|
+
const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
|
|
120
|
+
const seo = await writeSeoFiles(report, origin ?? DEFAULT_ORIGIN);
|
|
121
|
+
await Bun.stdout.write(
|
|
122
|
+
\`\${JSON.stringify({ ok: true, out: report.out, emitted: report.pages, skipped: report.skipped, unmeasured: report.unmeasured, report: report.report, seo })}\\n\`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
/** The two files, in the order a deploy meets them: the process, then the artifact. */
|
|
128
|
+
export const entryFiles = (): readonly GeneratedFile[] => [
|
|
129
|
+
{ path: 'apps/web/server.ts', contents: server() },
|
|
130
|
+
{ path: 'apps/web/prerender.ts', contents: prerender() },
|
|
131
|
+
];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The `guards/` directory `x new` writes, and the one list that names every guard in it.
|
|
2
|
+
//
|
|
3
|
+
// `x new` shipped ZERO guards while the `AGENTS.md` it writes stated nine non-negotiables — five of
|
|
4
|
+
// which nothing enforced, each measured green on `x verify` in a scaffolded app. The mechanism was
|
|
5
|
+
// never missing: `guards/` is discovered, not registered, and runs inside the `boundaries` step
|
|
6
|
+
// (`packages/cli/src/guards.ts`). What was missing is any guard to discover. Four of the five are
|
|
7
|
+
// here; the fifth, money-as-float, has no static signature and is answered by the `Money` type
|
|
8
|
+
// instead — `scaffold-docs.ts` says so where an author reads it.
|
|
9
|
+
|
|
10
|
+
import { bareErrorGuardFiles } from './guard-bare-error';
|
|
11
|
+
import { rawColourGuardFiles } from './guard-raw-colour';
|
|
12
|
+
import { untranslatedStringGuardFiles } from './guard-untranslated-string';
|
|
13
|
+
import { unzonedDateGuardFiles } from './guard-unzoned-date';
|
|
14
|
+
import type { GeneratedFile } from './naming';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every guard `x new` ships, each with its test. One module per guard, because an app deletes a
|
|
18
|
+
* rule it does not want by deleting one file — and a guard the app then writes for itself is
|
|
19
|
+
* `x g guard <name>`, the same shape.
|
|
20
|
+
*/
|
|
21
|
+
export const scaffoldGuardFiles = (): readonly GeneratedFile[] => [
|
|
22
|
+
...bareErrorGuardFiles(),
|
|
23
|
+
...rawColourGuardFiles(),
|
|
24
|
+
...untranslatedStringGuardFiles(),
|
|
25
|
+
...unzonedDateGuardFiles(),
|
|
26
|
+
];
|
|
@@ -12,6 +12,7 @@ import { dbPackageFiles } from './scaffold-db-package';
|
|
|
12
12
|
import { docsFiles } from './scaffold-docs';
|
|
13
13
|
import { domainPackageFiles } from './scaffold-domain-package';
|
|
14
14
|
import { envExampleSource, envSchemaSource } from './scaffold-env';
|
|
15
|
+
import { scaffoldGuardFiles } from './scaffold-guards';
|
|
15
16
|
import { i18nFiles } from './scaffold-i18n';
|
|
16
17
|
import { mcpPackageFiles } from './scaffold-mcp-package';
|
|
17
18
|
import { uiPackageFiles } from './scaffold-ui-package';
|
|
@@ -70,6 +71,7 @@ const rootPackage = (app: NameSet, version: string): string => `{
|
|
|
70
71
|
"@ultimat3/query": "^${version}",
|
|
71
72
|
"@ultimat3/render": "^${version}",
|
|
72
73
|
"@ultimat3/schema": "^${version}",
|
|
74
|
+
"@ultimat3/seo": "^${version}",
|
|
73
75
|
"@ultimat3/ui": "^${version}",
|
|
74
76
|
"solid-js": "1.9.14"
|
|
75
77
|
},
|
|
@@ -121,11 +123,13 @@ const envDeclaration = (): string => `${envSchemaSource()}
|
|
|
121
123
|
export const env = defineEnv(envSchema);`;
|
|
122
124
|
|
|
123
125
|
/**
|
|
124
|
-
* No `installPrompt
|
|
125
|
-
* `
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
126
|
+
* No `installPrompt`, no `afterSignInPath`, no `modelEnv`. All three were declared by
|
|
127
|
+
* `defineConfig`, defaulted by it, and read by NO file; all three are DELETED from
|
|
128
|
+
* `packages/core/src/config.ts` as of 2026-08-22, whose header now records the removal rather than
|
|
129
|
+
* the marker this comment used to cite. So scaffolding one is no longer a switch with no wire — it
|
|
130
|
+
* is `TS2353` in the generated app's first `x verify`. The note belongs HERE rather than in the
|
|
131
|
+
* emitted file: an app author has no use for a comment about keys their config does not name.
|
|
132
|
+
* `scaffold-config.test.ts` is what keeps them from growing back.
|
|
129
133
|
*/
|
|
130
134
|
const appConfig = (
|
|
131
135
|
app: NameSet,
|
|
@@ -145,7 +149,9 @@ export const config = defineConfig({
|
|
|
145
149
|
defaultCurrency: 'USD',
|
|
146
150
|
// Env KEYS, never the value: the same image deploys to every environment. The database is
|
|
147
151
|
// configured entirely from the environment — \`DATABASE_URL\` and \`DATABASE_POOL_MAX\`.
|
|
148
|
-
cache:
|
|
152
|
+
// The tiers ARE the cache selection: add 'redis' to build the shared rung, which reads
|
|
153
|
+
// \`REDIS_URL\` and refuses the boot when it is unset.
|
|
154
|
+
cache: { tiers: ['request-memo', 'lru'] },
|
|
149
155
|
jobs: { queues: ['${app.kebab}-default'], concurrency: 4 },
|
|
150
156
|
// In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
|
|
151
157
|
realtime: { enabled: true, tier: 'live-queries', transport: 'memory' },
|
|
@@ -164,10 +170,34 @@ export const config = defineConfig({
|
|
|
164
170
|
// otherwise fail `lint` on a file no author typed and `x db gen` would rewrite anyway.
|
|
165
171
|
// `preset`, not `recommended`: the older key is deprecated from 2.5 on and every `bun run lint`
|
|
166
172
|
// in the scaffolded app printed the migration notice for a config the app never wrote by hand.
|
|
173
|
+
//
|
|
174
|
+
// `!**/.x` is the one that makes the app's fix chain terminate. `x build` writes MINIFIED island
|
|
175
|
+
// bundles to `.x/static/islands/<name>-<contenthash>.js`; without the exclusion `lint` reported
|
|
176
|
+
// ~175 `noCommaOperator`/`noAssignInExpressions` errors in Bun's own output, and the `fix:` for
|
|
177
|
+
// that step — `biome check --write .` — still exits 1, so `x verify` was red forever on a pristine
|
|
178
|
+
// scaffold the moment `x build` ran (which the `budgets` step's own `fix:` tells the author to do).
|
|
179
|
+
// `--unsafe` was worse: it REWROTE a content-hashed chunk in place, 55,499 → 83,605 bytes, so the
|
|
180
|
+
// name no longer matched the bytes and `.x/build-stats.json` no longer matched the artifact.
|
|
181
|
+
// `--no-example` hid it, because an app with no island has nothing under `.x/static/islands`.
|
|
182
|
+
//
|
|
183
|
+
// `vcs.useIgnoreFile` is the second half and not a duplicate of the first: it makes every future
|
|
184
|
+
// generated directory the app adds to `.gitignore` excluded by the act of ignoring it, rather than
|
|
185
|
+
// by an edit to this file nobody will remember to make. It needs `.gitignore` to EXIST — Biome
|
|
186
|
+
// exits 1 with `couldn't find an ignore file` when it does not — which `x new` writes, and which
|
|
187
|
+
// is why the two are declared together rather than one of them alone.
|
|
167
188
|
const biome = (): string => `{
|
|
168
189
|
"$schema": "https://biomejs.dev/schemas/${BIOME_VERSION}/schema.json",
|
|
190
|
+
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
|
169
191
|
"files": {
|
|
170
|
-
"includes": [
|
|
192
|
+
"includes": [
|
|
193
|
+
"**",
|
|
194
|
+
"!**/.x",
|
|
195
|
+
"!**/dist",
|
|
196
|
+
"!**/.output",
|
|
197
|
+
"!**/migrations",
|
|
198
|
+
"!x.manifest.json",
|
|
199
|
+
"!openapi.json"
|
|
200
|
+
]
|
|
171
201
|
},
|
|
172
202
|
"formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
|
|
173
203
|
"linter": {
|
|
@@ -304,6 +334,9 @@ export function repoFiles(
|
|
|
304
334
|
// One call per workspace package, in write order. Each owns its own files (`scaffold-i18n.ts`
|
|
305
335
|
// already did), so this list stays a table of contents rather than a second copy of every
|
|
306
336
|
// package's contents.
|
|
337
|
+
// The app's own conventions, as build errors. Not a package — `guards/` sits at the repo root
|
|
338
|
+
// because the gate discovers it there, and every rule it holds is about the whole app.
|
|
339
|
+
...scaffoldGuardFiles(),
|
|
307
340
|
...domainPackageFiles(app),
|
|
308
341
|
...dbPackageFiles(app, example),
|
|
309
342
|
...i18nFiles(app, version),
|
package/src/test-select.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// validation behind the type positional and `--sample`. Selection only — nothing here splits a
|
|
3
3
|
// shard or spawns a process, so a wrong file list is always this file's bug and never a race.
|
|
4
4
|
|
|
5
|
+
import { join } from 'node:path';
|
|
5
6
|
// Bun ships no equivalent: `join` builds the host-separator path from the scan root to a hit.
|
|
6
7
|
// Sizing is Bun's own (`Bun.file().size`), so nothing here reaches for `node:fs`.
|
|
7
|
-
import {
|
|
8
|
+
import { nearestName } from '@ultimat3/core';
|
|
8
9
|
import { BadFlagError } from './errors';
|
|
9
10
|
import type { ParsedArgs } from './parse';
|
|
10
|
-
import { flagString
|
|
11
|
+
import { flagString } from './parse';
|
|
11
12
|
import type { TestType } from './verify-tests';
|
|
12
13
|
import { ownerOf, TEST_TYPES } from './verify-tests';
|
|
13
14
|
|
|
@@ -103,7 +104,7 @@ export function readType(raw: string | undefined): TestType | undefined {
|
|
|
103
104
|
if (raw === undefined) return undefined;
|
|
104
105
|
const known: readonly string[] = TEST_TYPES;
|
|
105
106
|
if (known.includes(raw)) return raw as TestType;
|
|
106
|
-
const suggestion =
|
|
107
|
+
const suggestion = nearestName(raw, known);
|
|
107
108
|
throw new BadFlagError({
|
|
108
109
|
flag: 'type',
|
|
109
110
|
command: 'test',
|
package/src/verify-run.ts
CHANGED
|
@@ -16,6 +16,10 @@ import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
|
|
|
16
16
|
/**
|
|
17
17
|
* Run every step in order, never bailing early: an agent fixing three things at once needs all
|
|
18
18
|
* three findings from one run, not one per round-trip.
|
|
19
|
+
*
|
|
20
|
+
* `ctx.only` narrows the list to one step. The narrowing lives HERE rather than in `cmd-verify.ts`
|
|
21
|
+
* so that every caller of the runner — the command, `x build`, the MCP host — gets the banner and
|
|
22
|
+
* the `--json` flag with it, instead of one of them filtering a list quietly.
|
|
19
23
|
*/
|
|
20
24
|
export async function runVerify(
|
|
21
25
|
steps: readonly VerifyStep[],
|
|
@@ -23,7 +27,8 @@ export async function runVerify(
|
|
|
23
27
|
): Promise<CommandResult> {
|
|
24
28
|
const floor = await readVerifyFloor(ctx.root);
|
|
25
29
|
const results: StepResult[] = [];
|
|
26
|
-
|
|
30
|
+
const selected = ctx.only === undefined ? steps : steps.filter((step) => step.name === ctx.only);
|
|
31
|
+
for (const step of selected) {
|
|
27
32
|
const applies = step.applies === undefined ? true : await step.applies(ctx);
|
|
28
33
|
if (!applies) {
|
|
29
34
|
// A skip this repo already ruled out is not a skip. The step ran here before — the floor is
|
|
@@ -67,14 +72,31 @@ export async function runVerify(
|
|
|
67
72
|
const failedSteps = results.filter((step) => !step.ok).map((step) => step.name);
|
|
68
73
|
const skippedSteps = results.filter((step) => step.skipped === true).map((step) => step.name);
|
|
69
74
|
const totalMs = results.reduce((sum, step) => sum + step.durationMs, 0);
|
|
75
|
+
const summary = verifySummary({
|
|
76
|
+
results,
|
|
77
|
+
failed: failedSteps,
|
|
78
|
+
skipped: skippedSteps,
|
|
79
|
+
totalMs,
|
|
80
|
+
});
|
|
70
81
|
return {
|
|
71
82
|
ok: failedSteps.length === 0,
|
|
72
83
|
command: 'verify',
|
|
73
|
-
|
|
84
|
+
// Rendered through the catalog like every other summary this file emits; the machine marker
|
|
85
|
+
// is `data.notAGateRun` below. It was a bare `NOT A GATE RUN` constant, which put one
|
|
86
|
+
// user-facing string outside `messages.ts` for a fact `--json` was already carrying twice.
|
|
87
|
+
summary: ctx.only === undefined ? summary : msg('cli.verify.notAGateRun', { summary }),
|
|
74
88
|
steps: results,
|
|
75
89
|
// `skipped` is a list beside `failed` and not a count, because the two answer the same kind of
|
|
76
90
|
// question — *which* steps, not how many — and a caller ratcheting on coverage needs the names.
|
|
77
|
-
data: {
|
|
91
|
+
data: {
|
|
92
|
+
failed: failedSteps,
|
|
93
|
+
skipped: skippedSteps,
|
|
94
|
+
durationMs: totalMs,
|
|
95
|
+
// A BOOLEAN beside the banner, so a reader of `--json` never has to substring-match a
|
|
96
|
+
// summary line to learn that this run checked one thing.
|
|
97
|
+
...(ctx.only === undefined ? {} : { notAGateRun: true, only: ctx.only }),
|
|
98
|
+
},
|
|
99
|
+
// The step's own status: one step, so `failedSteps` is that step and nothing else.
|
|
78
100
|
exitCode: failedSteps.length === 0 ? 0 : 1,
|
|
79
101
|
};
|
|
80
102
|
}
|
package/src/verify-step.ts
CHANGED
|
@@ -59,10 +59,19 @@ export interface VerifyContext {
|
|
|
59
59
|
readonly hostChecks?: Partial<Record<VerifyStepName, HostCheck>>;
|
|
60
60
|
/**
|
|
61
61
|
* How wide the parallel test steps go. Absent means `defaultWorkers()` — a knob, never a
|
|
62
|
-
* narrowing: no value of it changes which steps run or what "green" means
|
|
63
|
-
* the only flag `x verify` accepts beyond the global ones.
|
|
62
|
+
* narrowing: no value of it changes which steps run or what "green" means.
|
|
64
63
|
*/
|
|
65
64
|
readonly workers?: number;
|
|
65
|
+
/**
|
|
66
|
+
* ONE step, by name — an iteration loop, and the one thing here that IS a narrowing. Every
|
|
67
|
+
* iteration of the whole gate costs ~18s (14s of it `tsc -b`), which is the cost of asking a
|
|
68
|
+
* question about one step. It does not weaken axiom 5, and the two rules that keep it honest are
|
|
69
|
+
* mechanical rather than remembered: a run with this set prints `NOT A GATE RUN` in the summary
|
|
70
|
+
* AND carries `notAGateRun` in `--json` (`verify-run.ts`), so no reader of either can mistake it
|
|
71
|
+
* for the gate; and nothing writes `x.verify.json`, so the suite floor cannot be lowered by a
|
|
72
|
+
* run that never executed the suites. Green still means the no-flag run, unchanged.
|
|
73
|
+
*/
|
|
74
|
+
readonly only?: VerifyStepName;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
export interface StepOutcome {
|
package/src/verify-tests.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// and `join` builds the host-separator path to its config file.
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import type { TestType } from '@ultimat3/testing';
|
|
14
|
+
import { TEST_TYPES } from '@ultimat3/testing';
|
|
13
15
|
import { checkEvalBaselines, checkEvalCoverage, checkEvalRecording } from './app-evals';
|
|
14
16
|
import { APP_CONFIG_FILE } from './app-root';
|
|
15
17
|
import { countsOf } from './test-counts';
|
|
@@ -20,9 +22,15 @@ import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
|
|
|
20
22
|
import { fromExec, fromFindings } from './verify-step';
|
|
21
23
|
import { runParallel } from './verify-test-run';
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The six types are `@ultimat3/testing`'s declaration, not a list restated here: `unitTest`,
|
|
27
|
+
* `contractTest` and the rest prefix a test's reported name with one of these words, and this
|
|
28
|
+
* package selects a suite by the same word. A copy is one edit away from a step that discovers
|
|
29
|
+
* files no helper produces. `cli -> testing` is a declared sideways edge (`scripts/lib/tiers.ts`)
|
|
30
|
+
* and `@ultimat3/testing` is already a runtime dependency of this package.
|
|
31
|
+
*/
|
|
32
|
+
export type { TestType } from '@ultimat3/testing';
|
|
33
|
+
export { TEST_TYPES } from '@ultimat3/testing';
|
|
26
34
|
|
|
27
35
|
type TypedTest = Exclude<TestType, 'unit'>;
|
|
28
36
|
|
package/src/write-line.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The two writes every published entry point uses, one per fd — `packages/cli/src/bin.ts` and
|
|
2
|
+
// `create-ultimate`'s, the second of which shipped the `process.stdout.write` + `process.exit`
|
|
3
|
+
// pair the note below rules out. fd 2 exists because fd 1 is not always a log: under
|
|
4
|
+
// `x mcp serve --transport stdio` it is the protocol, under `--json` one document a caller parses.
|
|
4
5
|
|
|
5
6
|
// `node:fs`, and unavoidable: Bun has no synchronous stdout write of its own.
|
|
6
7
|
import { writeSync } from 'node:fs';
|
|
@@ -21,14 +22,31 @@ import { writeSync } from 'node:fs';
|
|
|
21
22
|
* took the whole command down on a runner, emitting nothing at all. The reader drains in
|
|
22
23
|
* microseconds; the retry is the correct response to "would block".
|
|
23
24
|
*/
|
|
24
|
-
|
|
25
|
+
function writeTo(fd: 1 | 2, line: string): void {
|
|
25
26
|
const buffer = Buffer.from(`${line}\n`);
|
|
26
27
|
let written = 0;
|
|
27
28
|
while (written < buffer.length) {
|
|
28
29
|
try {
|
|
29
|
-
written += writeSync(
|
|
30
|
+
written += writeSync(fd, buffer, written, buffer.length - written);
|
|
30
31
|
} catch (cause) {
|
|
31
32
|
if ((cause as NodeJS.ErrnoException).code !== 'EAGAIN') throw cause;
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
}
|
|
36
|
+
|
|
37
|
+
export function writeLine(line: string): void {
|
|
38
|
+
writeTo(1, line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The same write, on fd 2: for a line that is not the command's answer. `dispatch` sends a result
|
|
43
|
+
* here when it declares `stream: 'stderr'` — `x mcp serve --transport stdio`, whose stdout carries
|
|
44
|
+
* JSON-RPC frames and where a `✓ …` banner is a malformed one to whatever is reading.
|
|
45
|
+
*
|
|
46
|
+
* Every guarantee above is the same guarantee here, and that is the reason this is one loop and
|
|
47
|
+
* not two: fd 2 is a pipe under `2>` and in CI exactly as fd 1 is, so a second copy would be a
|
|
48
|
+
* second place for the truncation and the `EAGAIN` handling to drift apart.
|
|
49
|
+
*/
|
|
50
|
+
export function writeErrorLine(line: string): void {
|
|
51
|
+
writeTo(2, line);
|
|
52
|
+
}
|