@ultimat3/cli 7.0.0 → 8.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 (67) hide show
  1. package/CLAUDE.md +15 -1
  2. package/README.md +8 -3
  3. package/package.json +25 -25
  4. package/src/app-boundaries.ts +55 -5
  5. package/src/bin.ts +6 -3
  6. package/src/ci-log.ts +0 -0
  7. package/src/cmd-db-backfill.ts +240 -0
  8. package/src/cmd-db-branch.ts +3 -2
  9. package/src/cmd-db.ts +35 -156
  10. package/src/cmd-deploy.ts +37 -3
  11. package/src/cmd-dev.ts +7 -1
  12. package/src/cmd-errors.ts +2 -3
  13. package/src/cmd-fix.ts +3 -3
  14. package/src/cmd-i18n.ts +67 -5
  15. package/src/cmd-jobs.ts +27 -4
  16. package/src/cmd-mcp.ts +18 -9
  17. package/src/cmd-new.ts +91 -4
  18. package/src/cmd-policy.ts +3 -2
  19. package/src/cmd-pr.ts +55 -4
  20. package/src/cmd-registries.ts +3 -2
  21. package/src/cmd-shot.ts +68 -6
  22. package/src/cmd-tasks.ts +9 -4
  23. package/src/cmd-verify.ts +47 -6
  24. package/src/dev-cache.ts +1 -1
  25. package/src/dev-lock.ts +124 -12
  26. package/src/dev-queue.ts +12 -7
  27. package/src/dev-replicator.ts +3 -7
  28. package/src/dev-roles-fixture.ts +1 -1
  29. package/src/dev-roles.ts +40 -8
  30. package/src/dev-runtime.ts +96 -4
  31. package/src/dev-sync.ts +9 -4
  32. package/src/dispatch.ts +35 -5
  33. package/src/drift.ts +52 -7
  34. package/src/error-codes.ts +5 -0
  35. package/src/framework-scope.ts +57 -5
  36. package/src/generate-kinds.ts +19 -1
  37. package/src/i18n-registration.ts +67 -4
  38. package/src/index.ts +1 -1
  39. package/src/jobs-report.ts +10 -13
  40. package/src/mcp-errors.ts +3 -0
  41. package/src/messages.ts +12 -0
  42. package/src/output.ts +22 -2
  43. package/src/parse.ts +81 -37
  44. package/src/realtime-browser-probe-fixture.ts +9 -0
  45. package/src/runtime-overrides.ts +11 -3
  46. package/src/shot-settle.ts +57 -0
  47. package/src/shot-verdict.ts +27 -4
  48. package/src/sync-authenticator.ts +86 -14
  49. package/src/templates/guard-bare-error.ts +122 -0
  50. package/src/templates/guard-raw-colour.ts +138 -0
  51. package/src/templates/guard-untranslated-string.ts +138 -0
  52. package/src/templates/guard-unzoned-date.ts +142 -0
  53. package/src/templates/index.ts +3 -0
  54. package/src/templates/island.ts +2 -1
  55. package/src/templates/route.ts +1 -1
  56. package/src/templates/scaffold-app.ts +3 -82
  57. package/src/templates/scaffold-container.ts +30 -4
  58. package/src/templates/scaffold-db-package.ts +14 -6
  59. package/src/templates/scaffold-docs.ts +24 -13
  60. package/src/templates/scaffold-entries.ts +131 -0
  61. package/src/templates/scaffold-guards.ts +26 -0
  62. package/src/templates/scaffold-repo.ts +37 -6
  63. package/src/test-select.ts +4 -3
  64. package/src/verify-run.ts +25 -3
  65. package/src/verify-step.ts +11 -2
  66. package/src/verify-tests.ts +11 -3
  67. package/src/write-line.ts +23 -5
@@ -1,6 +1,6 @@
1
- // The generated app's `packages/db`: the entity re-export list the migration generator reads and
2
- // the deterministic seed. No business logic — that is the package's own stated boundary, and it is
3
- // why `example` reaches only the two files describing the slice's table.
1
+ // The generated app's `packages/db`: the entity re-export list the app's own modules import from
2
+ // and the deterministic seed. No business logic — that is the package's own stated boundary, and it
3
+ // is why `example` reaches only the two files describing the slice's table.
4
4
  //
5
5
  // No migration. `x db gen` is the ONE writer of `packages/db/migrations`, and a scaffold that hand-
6
6
  // wrote `0000_initial.sql` was a second one: it declared a `posts` table the generator had never
@@ -56,8 +56,16 @@ export * as schema from './schema';
56
56
  // never written, so each one ships its empty counterpart instead of a reference to a file that is
57
57
  // not there — `export { post } from …` alone made `x new --no-example` an app that cannot compile.
58
58
 
59
- const SCHEMA_HEADER = `// Every entity the app declares, re-exported here. This list is what the migration generator
60
- // reads, so an entity that is not exported here does not exist as far as the database is concerned.`;
59
+ // Not "what the migration generator reads" — that claim shipped into every generated app and was
60
+ // false. `x db gen` and `x verify`'s `drift` step both project the ENTITY REGISTRY that `loadApp`
61
+ // fills (`describeEntities()`, `packages/cli/src/app-entities.ts`), so an entity declared anywhere
62
+ // `loadApp` reaches is already in the migration whether or not this file names it. Re-exporting an
63
+ // entity here does exactly one thing, and it is worth doing: it gives `seed.ts` and every other
64
+ // consumer ONE import to reach the app's tables through.
65
+ const SCHEMA_HEADER = `// Every entity the app declares, re-exported here so the seed and anything else that needs a table
66
+ // reach them through one import. It is not what the migration generator reads: \`x db gen\` and the
67
+ // \`drift\` step project the entity registry, so an entity is in the migration because it was
68
+ // declared, never because it was listed here.`;
61
69
 
62
70
  const dbSchema = (app: NameSet, example: boolean): string =>
63
71
  example
@@ -65,7 +73,7 @@ const dbSchema = (app: NameSet, example: boolean): string =>
65
73
  export { post } from '@${app.kebab}/web/app/post/entity';
66
74
  `
67
75
  : `${SCHEMA_HEADER}
68
- // \`x g entity <name>\` writes the entity; add its export here so the database learns about it.
76
+ // \`x g entity <name>\` writes the entity; add its export here to reach it through \`@${app.kebab}/db\`.
69
77
  export {};
70
78
  `;
71
79
 
@@ -13,19 +13,30 @@ const agents = (app: NameSet): string => `# AGENTS.md
13
13
  Human-authored, short, stable. Facts live in \`x.manifest.json\`; this file holds only what an
14
14
  agent cannot infer from the code.
15
15
 
16
- | Rule | Detail |
17
- |---|---|
18
- | One gate | \`x verify\` — green means shippable. Never merge red. |
19
- | One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` |
20
- | Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf |
21
- | Data | routes call actions and queries; only \`repo.ts\` touches the database |
22
- | Errors | never \`throw new Error\` — subclass \`UltimateError\` with a code, a cause and a fix |
23
- | Money | integer minor units + ISO code, never a float |
24
- | Time | store UTC, format with an explicit IANA time zone |
25
- | Strings | every user-facing string goes through \`t()\` |
26
- | Colour | semantic tokens only, never a raw hex |
27
-
28
- Commands: \`x dev\`, \`x verify\`, \`x g <primitive>\`, \`x db branch create <name>\`, \`x doctor\`.
16
+ Every row below names what refuses it. A rule with nothing in the last column is a rule that does
17
+ not exist — five of these had an empty column and were each measured green on \`x verify\`.
18
+
19
+ | Rule | Detail | Refused by |
20
+ |---|---|---|
21
+ | One gate | \`x verify\` — green means shippable. Never merge red. | the gate itself |
22
+ | One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` | review |
23
+ | Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf | \`X_BOUNDARY_SITE_TO_APP\` |
24
+ | Data | routes call actions and queries; only \`repo.ts\` touches the database | \`X_BOUNDARY_ROUTE_TO_DB\` |
25
+ | Errors | never \`throw new Error\` — subclass \`UltimateError\` with a code, a cause and a fix | \`guards/bare-error.ts\` |
26
+ | Money | \`{ minor, currency }\`, never a float — \`money()\` on the column | the type: \`price: 19.99\` is TS2322, \`number\` is not \`MoneyInput\` |
27
+ | Time | store UTC, format with an explicit IANA time zone | \`guards/unzoned-date.ts\` |
28
+ | Strings | every user-facing string goes through \`t()\` | \`guards/untranslated-string.ts\` |
29
+ | Colour | semantic tokens only, never a raw hex | \`guards/raw-colour.ts\` |
30
+
31
+ \`guards/\` is yours: each file is one rule, discovered by \`x verify\` and run inside its
32
+ \`boundaries\` step. Delete one to drop the rule, and \`x g guard <name>\` writes the next.
33
+
34
+ Money is the one row with no guard, deliberately: a float has no static signature a text rule can
35
+ see, and the type already fires — measured, \`price: 19.99\` in a seed is
36
+ \`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
37
+ it would be worse than the type that really does.
38
+
39
+ Commands: \`x dev\`, \`x verify\`, \`x g <primitive>\`, \`x g guard <name>\`, \`x db branch create <name>\`, \`x doctor\`.
29
40
 
30
41
  Project notes for ${app.kebab}: replace this line with the conventions a newcomer could not guess.
31
42
  `;
@@ -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,
@@ -164,10 +168,34 @@ export const config = defineConfig({
164
168
  // otherwise fail `lint` on a file no author typed and `x db gen` would rewrite anyway.
165
169
  // `preset`, not `recommended`: the older key is deprecated from 2.5 on and every `bun run lint`
166
170
  // in the scaffolded app printed the migration notice for a config the app never wrote by hand.
171
+ //
172
+ // `!**/.x` is the one that makes the app's fix chain terminate. `x build` writes MINIFIED island
173
+ // bundles to `.x/static/islands/<name>-<contenthash>.js`; without the exclusion `lint` reported
174
+ // ~175 `noCommaOperator`/`noAssignInExpressions` errors in Bun's own output, and the `fix:` for
175
+ // that step — `biome check --write .` — still exits 1, so `x verify` was red forever on a pristine
176
+ // scaffold the moment `x build` ran (which the `budgets` step's own `fix:` tells the author to do).
177
+ // `--unsafe` was worse: it REWROTE a content-hashed chunk in place, 55,499 → 83,605 bytes, so the
178
+ // name no longer matched the bytes and `.x/build-stats.json` no longer matched the artifact.
179
+ // `--no-example` hid it, because an app with no island has nothing under `.x/static/islands`.
180
+ //
181
+ // `vcs.useIgnoreFile` is the second half and not a duplicate of the first: it makes every future
182
+ // generated directory the app adds to `.gitignore` excluded by the act of ignoring it, rather than
183
+ // by an edit to this file nobody will remember to make. It needs `.gitignore` to EXIST — Biome
184
+ // exits 1 with `couldn't find an ignore file` when it does not — which `x new` writes, and which
185
+ // is why the two are declared together rather than one of them alone.
167
186
  const biome = (): string => `{
168
187
  "$schema": "https://biomejs.dev/schemas/${BIOME_VERSION}/schema.json",
188
+ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
169
189
  "files": {
170
- "includes": ["**", "!**/migrations", "!x.manifest.json", "!openapi.json"]
190
+ "includes": [
191
+ "**",
192
+ "!**/.x",
193
+ "!**/dist",
194
+ "!**/.output",
195
+ "!**/migrations",
196
+ "!x.manifest.json",
197
+ "!openapi.json"
198
+ ]
171
199
  },
172
200
  "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
173
201
  "linter": {
@@ -304,6 +332,9 @@ export function repoFiles(
304
332
  // One call per workspace package, in write order. Each owns its own files (`scaffold-i18n.ts`
305
333
  // already did), so this list stays a table of contents rather than a second copy of every
306
334
  // package's contents.
335
+ // The app's own conventions, as build errors. Not a package — `guards/` sits at the repo root
336
+ // because the gate discovers it there, and every rule it holds is about the whole app.
337
+ ...scaffoldGuardFiles(),
307
338
  ...domainPackageFiles(app),
308
339
  ...dbPackageFiles(app, example),
309
340
  ...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
+ }