@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.
Files changed (78) hide show
  1. package/CLAUDE.md +24 -4
  2. package/README.md +8 -3
  3. package/package.json +26 -25
  4. package/src/app-boundaries.ts +55 -5
  5. package/src/app-load.ts +7 -0
  6. package/src/bin.ts +6 -3
  7. package/src/ci-log.ts +0 -0
  8. package/src/cmd-db-backfill.ts +240 -0
  9. package/src/cmd-db-branch.ts +3 -2
  10. package/src/cmd-db.ts +35 -156
  11. package/src/cmd-deploy.ts +43 -6
  12. package/src/cmd-dev.ts +7 -1
  13. package/src/cmd-errors.ts +2 -3
  14. package/src/cmd-fix.ts +3 -3
  15. package/src/cmd-i18n.ts +67 -5
  16. package/src/cmd-jobs.ts +27 -4
  17. package/src/cmd-mcp.ts +18 -9
  18. package/src/cmd-new.ts +91 -4
  19. package/src/cmd-policy.ts +3 -2
  20. package/src/cmd-pr.ts +55 -4
  21. package/src/cmd-registries.ts +3 -2
  22. package/src/cmd-shot.ts +68 -6
  23. package/src/cmd-tasks.ts +9 -4
  24. package/src/cmd-verify.ts +47 -6
  25. package/src/dev-assets.ts +4 -7
  26. package/src/dev-cache.ts +130 -33
  27. package/src/dev-lock.ts +124 -12
  28. package/src/dev-purge.ts +120 -0
  29. package/src/dev-queue.ts +39 -9
  30. package/src/dev-render.ts +11 -14
  31. package/src/dev-replicator.ts +3 -7
  32. package/src/dev-roles-fixture.ts +1 -1
  33. package/src/dev-roles.ts +40 -8
  34. package/src/dev-runtime.ts +137 -6
  35. package/src/dev-sync.ts +9 -4
  36. package/src/dispatch.ts +35 -5
  37. package/src/document-styles.ts +2 -1
  38. package/src/drift.ts +52 -7
  39. package/src/error-codes.ts +5 -0
  40. package/src/framework-scope.ts +57 -5
  41. package/src/generate-kinds.ts +19 -1
  42. package/src/i18n-registration.ts +67 -4
  43. package/src/index.ts +1 -1
  44. package/src/island-bundle.ts +2 -6
  45. package/src/island-styles.ts +1 -1
  46. package/src/jobs-report.ts +10 -13
  47. package/src/mcp-errors.ts +3 -0
  48. package/src/messages.ts +12 -0
  49. package/src/output.ts +22 -2
  50. package/src/parse.ts +81 -37
  51. package/src/prerender.ts +2 -1
  52. package/src/realtime-browser-probe-fixture.ts +9 -0
  53. package/src/runtime-overrides.ts +12 -4
  54. package/src/serve.ts +1 -1
  55. package/src/shot-settle.ts +57 -0
  56. package/src/shot-verdict.ts +27 -4
  57. package/src/solid-loader.ts +1 -1
  58. package/src/style-csp.ts +2 -1
  59. package/src/sync-authenticator.ts +86 -14
  60. package/src/templates/guard-bare-error.ts +122 -0
  61. package/src/templates/guard-raw-colour.ts +138 -0
  62. package/src/templates/guard-untranslated-string.ts +138 -0
  63. package/src/templates/guard-unzoned-date.ts +142 -0
  64. package/src/templates/index.ts +3 -0
  65. package/src/templates/island.ts +2 -1
  66. package/src/templates/route.ts +1 -1
  67. package/src/templates/scaffold-app.ts +3 -82
  68. package/src/templates/scaffold-container.ts +30 -4
  69. package/src/templates/scaffold-db-package.ts +14 -6
  70. package/src/templates/scaffold-docs.ts +34 -16
  71. package/src/templates/scaffold-entries.ts +131 -0
  72. package/src/templates/scaffold-guards.ts +26 -0
  73. package/src/templates/scaffold-repo.ts +40 -7
  74. package/src/test-select.ts +4 -3
  75. package/src/verify-run.ts +25 -3
  76. package/src/verify-step.ts +11 -2
  77. package/src/verify-tests.ts +11 -3
  78. 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`. `PwaConfig` declares it, `defineConfig` defaults it, and NO file reads it —
125
- * `packages/core/src/config.ts` carries the marker saying so. Scaffolding it wrote a switch with
126
- * no wire into every generated app, and the note belongs HERE rather than in the emitted file: an
127
- * app author has no use for a comment about a framework key their config does not name. Deleting
128
- * the key itself is core's edit; not writing it is this template's half.
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: { driver: 'memory', tiers: ['memo', 'lru'] },
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": ["**", "!**/migrations", "!x.manifest.json", "!openapi.json"]
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),
@@ -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 { join } from 'node:path';
8
+ import { nearestName } from '@ultimat3/core';
8
9
  import { BadFlagError } from './errors';
9
10
  import type { ParsedArgs } from './parse';
10
- import { flagString, nearest } from './parse';
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 = nearest(raw, known);
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
- for (const step of steps) {
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
- summary: verifySummary({ results, failed: failedSteps, skipped: skippedSteps, totalMs }),
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: { failed: failedSteps, skipped: skippedSteps, durationMs: totalMs },
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
  }
@@ -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, which is why this is
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 {
@@ -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
- export const TEST_TYPES = ['unit', 'contract', 'live', 'job', 'e2e', 'eval'] as const;
24
-
25
- export type TestType = (typeof TEST_TYPES)[number];
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 one stdout write every published entry point uses. Its own module because there are two of
2
- // them — `packages/cli/src/bin.ts` and `create-ultimate`'s — and the second shipped
3
- // `process.stdout.write` + `process.exit`, the exact pair the note below exists to rule out.
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
- export function writeLine(line: string): void {
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(1, buffer, written, buffer.length - written);
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
+ }