@ultimat3/cli 1.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
@@ -0,0 +1,49 @@
1
+ // The three contract files `x verify`'s `package-shape` step requires of every `packages/*` dir —
2
+ // enforced on a generated app exactly like it is on this repo, so `x new` has to ship them itself
3
+ // rather than leave the app's very first gate run red. Split out of scaffold-repo.ts to stay under
4
+ // the file-size ceiling; every `packages/*` entry there calls `packageShapeFiles` once.
5
+
6
+ import type { GeneratedFile, NameSet } from './naming';
7
+
8
+ const packageTsconfig = (includes: readonly string[]): string => `{
9
+ "extends": "../../tsconfig.json",
10
+ "include": [${includes.map((glob) => `"${glob}"`).join(', ')}]
11
+ }
12
+ `;
13
+
14
+ const packageReadme = (
15
+ app: NameSet,
16
+ name: string,
17
+ description: string,
18
+ ): string => `# @${app.kebab}/${name}
19
+
20
+ ${description}. Part of the ${app.kebab} monorepo — see the root README for how it fits.
21
+ `;
22
+
23
+ const packageClaude = (
24
+ app: NameSet,
25
+ name: string,
26
+ description: string,
27
+ ): string => `# @${app.kebab}/${name} — CLAUDE.md
28
+
29
+ ${description}.
30
+
31
+ - Gate: \`x verify\` from the repo root — this package has no gate of its own.
32
+ - Exports: \`src/index.ts\`, named exports only, no \`export *\`.
33
+ - Imports: \`@ultimat3/*\` and this app's own \`@${app.kebab}/*\` packages, never a sibling app.
34
+ `;
35
+
36
+ /** `src/index.ts` is the package's own file, written separately since its contents differ
37
+ * package to package — these three are identical in shape everywhere, except the tsconfig's
38
+ * `include`: a package whose data sits outside `src/` (i18n's catalog JSON) names an extra glob
39
+ * to reach it, so callers may override the default. */
40
+ export const packageShapeFiles = (
41
+ app: NameSet,
42
+ name: string,
43
+ description: string,
44
+ includes: readonly string[] = ['**/*.ts'],
45
+ ): readonly GeneratedFile[] => [
46
+ { path: `packages/${name}/README.md`, contents: packageReadme(app, name, description) },
47
+ { path: `packages/${name}/CLAUDE.md`, contents: packageClaude(app, name, description) },
48
+ { path: `packages/${name}/tsconfig.json`, contents: packageTsconfig(includes) },
49
+ ];
@@ -0,0 +1,427 @@
1
+ // The config half of what `x new` writes: the one config file, the tooling configs and the
2
+ // workspace packages. Committed defaults only — a fresh clone boots with `x dev` and no env
3
+ // scavenger hunt. The docs, shims and container files live in scaffold-docs.ts.
4
+
5
+ import type { GeneratedFile, NameSet } from './naming';
6
+ import { docsFiles } from './scaffold-docs';
7
+ import { i18nFiles } from './scaffold-i18n';
8
+ import { packageShapeFiles } from './scaffold-package-shape';
9
+
10
+ const rootPackage = (app: NameSet, version: string): string => `{
11
+ "name": "${app.kebab}",
12
+ "private": true,
13
+ "type": "module",
14
+ "workspaces": [
15
+ "apps/*",
16
+ "packages/*"
17
+ ],
18
+ "scripts": {
19
+ "setup": "bin/setup",
20
+ "dev": "x dev",
21
+ "check": "bin/check",
22
+ "verify": "x verify",
23
+ "typecheck": "tsc -b --pretty",
24
+ "lint": "biome check .",
25
+ "test": "bun test",
26
+ "db:migrate": "x db migrate",
27
+ "db:seed": "bun run packages/db/src/seed.ts"
28
+ },
29
+ "devDependencies": {
30
+ "@biomejs/biome": "^2.4.15",
31
+ "@electric-sql/pglite": "^0.5.4",
32
+ "@types/bun": "^1.3.14",
33
+ "@ultimat3/testing": "^${version}",
34
+ "typescript": "^6.0.3"
35
+ },
36
+ "dependencies": {
37
+ "@ultimat3/action": "^${version}",
38
+ "@ultimat3/cache": "^${version}",
39
+ "@ultimat3/cli": "^${version}",
40
+ "@ultimat3/core": "^${version}",
41
+ "@ultimat3/db": "^${version}",
42
+ "@ultimat3/entity": "^${version}",
43
+ "@ultimat3/i18n": "^${version}",
44
+ "@ultimat3/jobs": "^${version}",
45
+ "@ultimat3/mcp": "^${version}",
46
+ "@ultimat3/policy": "^${version}",
47
+ "@ultimat3/pwa": "^${version}",
48
+ "@ultimat3/query": "^${version}",
49
+ "@ultimat3/render": "^${version}",
50
+ "@ultimat3/ui": "^${version}",
51
+ "solid-js": "2.0.0-experimental.16"
52
+ },
53
+ "engines": {
54
+ "bun": ">=1.3.0"
55
+ }
56
+ }
57
+ `;
58
+
59
+ const rootTsconfig = (app: NameSet): string => `{
60
+ "compilerOptions": {
61
+ "target": "ES2023",
62
+ "module": "ESNext",
63
+ "moduleResolution": "bundler",
64
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
65
+ "types": ["bun"],
66
+ "paths": {
67
+ "@${app.kebab}/*": ["./packages/*/src"]
68
+ },
69
+ "strict": true,
70
+ "noUncheckedIndexedAccess": true,
71
+ "exactOptionalPropertyTypes": true,
72
+ "verbatimModuleSyntax": true,
73
+ "isolatedModules": true,
74
+ "skipLibCheck": true,
75
+ "noEmit": true,
76
+ "resolveJsonModule": true,
77
+ "jsx": "preserve",
78
+ "jsxImportSource": "solid-js"
79
+ },
80
+ "exclude": ["node_modules", "dist", ".x"]
81
+ }
82
+ `;
83
+
84
+ const appConfig = (
85
+ app: NameSet,
86
+ ): string => `// The one config file. Everything the app needs to boot is here, typed and validated at startup —
87
+ // a missing value fails the boot with the exact command that fixes it, never at the first request.
88
+ // A named export, never a default: the CLI and the runtime both import \`config\` by name.
89
+ import { defineConfig } from '@ultimat3/core';
90
+
91
+ export const config = defineConfig({
92
+ name: '${app.kebab}',
93
+ locales: ['en'],
94
+ defaultLocale: 'en',
95
+ defaultTimeZone: 'UTC',
96
+ defaultCurrency: 'USD',
97
+ // Env KEYS, never the value: the same image deploys to every environment.
98
+ database: { urlEnv: 'DATABASE_URL', poolSize: 10 },
99
+ cache: { driver: 'memory', tiers: ['memo', 'lru'] },
100
+ jobs: { driver: 'postgres', queues: ['${app.kebab}-default'], concurrency: 4 },
101
+ // In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
102
+ realtime: { enabled: true, tier: 'live-queries', transport: 'memory' },
103
+ pwa: { enabled: true, offline: 'runtime', installPrompt: true },
104
+ ai: { mcp: { expose: true, path: '/mcp' } },
105
+ });
106
+ `;
107
+
108
+ const biome = (): string => `{
109
+ "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
110
+ // x.manifest.json and openapi.json are emitted byte-for-byte by \`x manifest\`; a formatter
111
+ // rewriting them puts \`x manifest\` and \`x verify\` in a loop neither can win.
112
+ "files": { "includes": ["**", "!x.manifest.json", "!openapi.json"] },
113
+ "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
114
+ "linter": {
115
+ "rules": {
116
+ "recommended": true,
117
+ "suspicious": { "noExplicitAny": "error" },
118
+ "correctness": { "noUnusedVariables": "error", "noUnusedImports": "error" }
119
+ }
120
+ },
121
+ "javascript": {
122
+ "formatter": { "quoteStyle": "single", "semicolons": "always", "trailingCommas": "all" }
123
+ }
124
+ }
125
+ `;
126
+
127
+ const bunfig = (): string => `[test]
128
+ root = "."
129
+ # Frozen clock, seeded RNG, sealed network — nondeterminism in a test is a bug.
130
+ preload = ["@ultimat3/testing/preload"]
131
+ `;
132
+
133
+ const scssTypes =
134
+ (): string => `// SCSS modules resolve to a class-name map at build time. Ambient because an import cannot
135
+ // reach a declaration file — every surface names this file in its tsconfig "include".
136
+
137
+ declare module '*.module.scss' {
138
+ const classes: Readonly<Record<string, string>>;
139
+ export default classes;
140
+ }
141
+ `;
142
+
143
+ const gitignore = (): string => `node_modules/
144
+ .x/
145
+ dist/
146
+ *.tsbuildinfo
147
+ .env
148
+ .env.*.local
149
+ coverage/
150
+ playwright-report/
151
+ test-results/
152
+ `;
153
+
154
+ const envDevelopment =
155
+ (): string => `# Committed non-secret defaults. Per-box secrets go in .env.development.local, which wins.
156
+ # Empty DATABASE_URL means "embedded": x dev runs PGlite in-process, no Docker required.
157
+ DATABASE_URL=
158
+ NATS_URL=
159
+ S3_ENDPOINT=
160
+ PORT=3000
161
+ ROLE=web
162
+ `;
163
+
164
+ const domainPackage = (app: NameSet, name: string, description: string): string => `{
165
+ "name": "@${app.kebab}/${name}",
166
+ "version": "0.0.0",
167
+ "private": true,
168
+ "type": "module",
169
+ "description": "${description}",
170
+ "exports": {
171
+ ".": "./src/index.ts"
172
+ },
173
+ "scripts": {
174
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
175
+ }
176
+ }
177
+ `;
178
+
179
+ const domainIndex =
180
+ (): string => `// Pure types and constants. No I/O of any kind: no fs, no network, no database, no env reads.
181
+ export const ROLES = ['owner', 'member', 'viewer'] as const;
182
+
183
+ export type Role = (typeof ROLES)[number];
184
+
185
+ export interface Money {
186
+ readonly minor: number;
187
+ readonly currency: string;
188
+ }
189
+
190
+ export const zero = (currency: string): Money => ({ minor: 0, currency });
191
+
192
+ export const add = (a: Money, b: Money): Money => {
193
+ if (a.currency !== b.currency) throw new RangeError(\`cannot add \${a.currency} to \${b.currency}\`);
194
+ return { minor: a.minor + b.minor, currency: a.currency };
195
+ };
196
+ `;
197
+
198
+ const domainTest = (): string => `import { expect } from 'bun:test';
199
+ import { unitTest } from '@ultimat3/testing';
200
+ import { add, zero } from './index';
201
+
202
+ unitTest('money adds in minor units', () => {
203
+ expect(add({ minor: 1050, currency: 'USD' }, { minor: 250, currency: 'USD' })).toEqual({
204
+ minor: 1300,
205
+ currency: 'USD',
206
+ });
207
+ });
208
+
209
+ unitTest('money refuses to add across currencies', () => {
210
+ expect(() => add(zero('USD'), zero('EUR'))).toThrow();
211
+ });
212
+ `;
213
+
214
+ const dbIndex =
215
+ (): string => `// Schema and migrations only — no business logic lives in this package. The client itself is
216
+ // @ultimat3/db's: one connection pool, sized by ROLE, shared by every package in the app.
217
+ export type { DbClient, SqlFragment } from '@ultimat3/db';
218
+ export { db, sql, withTransaction } from '@ultimat3/db';
219
+ export * as schema from './schema';
220
+ `;
221
+
222
+ // The four pieces below describe the example slice's table. Under `--no-example` that slice is
223
+ // never written, so each one ships its empty counterpart instead of a reference to a file that is
224
+ // not there — `export { post } from …` alone made `x new --no-example` an app that cannot compile.
225
+
226
+ const SCHEMA_HEADER = `// Every entity the app declares, re-exported here. This list is what the migration generator
227
+ // reads, so an entity that is not exported here does not exist as far as the database is concerned.`;
228
+
229
+ /**
230
+ * `bun run db:seed`'s entry point. Identical either way — only the rows differ. Interpolated, not
231
+ * nested, so it carries exactly the escaping a single template literal needs.
232
+ */
233
+ const SEED_MAIN = `
234
+
235
+ if (import.meta.main) {
236
+ const count = await seed();
237
+ // Bun's stdout, not process.stdout: one runtime, one API. Awaited because the write resolves
238
+ // asynchronously, and this JSON line is the whole output of \`bun run db:seed\`.
239
+ await Bun.stdout.write(\`\${JSON.stringify({ ok: true, seeded: count })}\\n\`);
240
+ }
241
+ `;
242
+
243
+ const dbSchema = (app: NameSet, example: boolean): string =>
244
+ example
245
+ ? `${SCHEMA_HEADER}
246
+ export { post } from '@${app.kebab}/web/app/post/entity';
247
+ `
248
+ : `${SCHEMA_HEADER}
249
+ // \`x g entity <name>\` writes the entity; add its export here so the database learns about it.
250
+ export {};
251
+ `;
252
+
253
+ const dbSeed = (app: NameSet, example: boolean): string =>
254
+ example
255
+ ? `// Deterministic seed: same rows every time, so a test and a demo see the same database.
256
+ import { db, sql } from '@ultimat3/db';
257
+
258
+ const ORG = '00000000-0000-0000-0000-000000000002';
259
+
260
+ export async function seed(): Promise<number> {
261
+ const rows = [
262
+ { id: '00000000-0000-0000-0000-000000000101', title: 'Hello ${app.pascal}', minor: 0 },
263
+ { id: '00000000-0000-0000-0000-000000000102', title: 'Second post', minor: 1900 },
264
+ ];
265
+ for (const row of rows) {
266
+ // Idempotent by primary key, so re-seeding a branch database is a no-op rather than a crash.
267
+ await db().execute(sql\`
268
+ insert into posts (id, org_id, title, price_minor, price_currency)
269
+ values (\${row.id}, \${ORG}, \${row.title}, \${row.minor}, 'USD')
270
+ on conflict (id) do nothing\`);
271
+ }
272
+ return rows.length;
273
+ }${SEED_MAIN}`
274
+ : `// Deterministic seed: same rows every time, so a test and a demo see the same database.
275
+ // No entity is declared yet, so there is nothing to insert — the shape stays, so the first
276
+ // \`x g entity\` has one obvious place to seed from.
277
+
278
+ export async function seed(): Promise<number> {
279
+ return 0;
280
+ }${SEED_MAIN}`;
281
+
282
+ const migration = (example: boolean): string =>
283
+ example
284
+ ? `-- 0000_initial: the example feature slice. Reversible: the down section is required.
285
+ CREATE TABLE IF NOT EXISTS posts (
286
+ id uuid PRIMARY KEY,
287
+ org_id uuid NOT NULL,
288
+ title varchar(200) NOT NULL,
289
+ price_minor integer NOT NULL DEFAULT 0,
290
+ price_currency char(3) NOT NULL DEFAULT 'USD',
291
+ created_at timestamptz NOT NULL DEFAULT now()
292
+ );
293
+ CREATE INDEX IF NOT EXISTS posts_org_created_idx ON posts (org_id, created_at);
294
+
295
+ -- down
296
+ -- DROP INDEX IF EXISTS posts_org_created_idx;
297
+ -- DROP TABLE IF EXISTS posts;
298
+ `
299
+ : `-- 0000_initial: no entity is declared yet, so this migration creates nothing. It exists so the
300
+ -- schema hash beside it has a migration to belong to, and \`x verify\` sees no drift on run one.
301
+ -- Reversible: the down section is required.
302
+
303
+ -- down
304
+ `;
305
+
306
+ const uiIndex =
307
+ (): string => `// App components on top of @ultimat3/ui. Same byte budgets as shared/: this package is imported
308
+ // by site/, so a chart library in here costs the landing page.
309
+ export { Card } from './card';
310
+ `;
311
+
312
+ const uiCard = (): string => `import type { JSX } from 'solid-js';
313
+ import styles from './card.module.scss';
314
+
315
+ export interface CardProps {
316
+ readonly title: string;
317
+ readonly children?: JSX.Element;
318
+ }
319
+
320
+ export function Card(props: CardProps) {
321
+ return (
322
+ <section class={styles.card}>
323
+ <h2 class={styles.title}>{props.title}</h2>
324
+ {props.children}
325
+ </section>
326
+ );
327
+ }
328
+ `;
329
+
330
+ const uiCardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
331
+
332
+ .card {
333
+ padding: tokens.$space-4;
334
+ border-radius: tokens.$radius-md;
335
+ background: tokens.$surface-raised;
336
+ color: tokens.$text-primary;
337
+ }
338
+
339
+ .title {
340
+ font: tokens.$text-heading-sm;
341
+ }
342
+ `;
343
+
344
+ const mcpIndex = (
345
+ app: NameSet,
346
+ ): string => `// The app's own MCP tools. Every action with mcp.expose is already a tool; add app-specific
347
+ // read-only helpers here. Authorization is the action's policy, unchanged.
348
+ import * as api from '@${app.kebab}/web/api/health';
349
+ import { registerActions } from '@ultimat3/action';
350
+ import { defineAppMcp } from '@ultimat3/mcp';
351
+
352
+ // Names come from export names, so the registry agrees with the module the app already wrote.
353
+ registerActions(api);
354
+
355
+ // \`include: 'exposed'\` projects straight from the registry. Re-listing the actions here would
356
+ // copy \`mcp: { expose: true }\` into a second place, and the copy goes stale in silence.
357
+ export const mcp = defineAppMcp({
358
+ name: '${app.kebab}',
359
+ include: 'exposed',
360
+ });
361
+ `;
362
+
363
+ const mcpTest = (): string => `import { expect, unitTest } from '@ultimat3/testing';
364
+ import { mcp } from './index';
365
+
366
+ unitTest('the app exposes its actions as MCP tools', () => {
367
+ expect(mcp.tools.length).toBeGreaterThan(0);
368
+ // Every projected tool must describe itself: an agent picks a tool by its description. Assert
369
+ // on the value, not its length — a failure then prints the empty description, not "0 > 0".
370
+ for (const tool of mcp.tools) expect(tool.description).not.toBe('');
371
+ });
372
+ `;
373
+
374
+ /**
375
+ * `example` reaches only the four files that describe the slice's table — schema, seed, initial
376
+ * migration, and nothing in the catalog. Everything else is the same app either way, which is what
377
+ * `--no-example` promises: the same shape with an empty `app/`.
378
+ */
379
+ export function repoFiles(
380
+ app: NameSet,
381
+ version: string,
382
+ example: boolean,
383
+ ): readonly GeneratedFile[] {
384
+ return [
385
+ ...docsFiles(app),
386
+ { path: 'package.json', contents: rootPackage(app, version) },
387
+ { path: 'tsconfig.json', contents: rootTsconfig(app) },
388
+ { path: 'biome.json', contents: biome() },
389
+ { path: 'bunfig.toml', contents: bunfig() },
390
+ { path: 'app.config.ts', contents: appConfig(app) },
391
+ { path: 'types/scss.d.ts', contents: scssTypes() },
392
+ { path: '.gitignore', contents: gitignore() },
393
+ { path: '.env.development', contents: envDevelopment() },
394
+ {
395
+ path: 'packages/domain/package.json',
396
+ contents: domainPackage(app, 'domain', 'Pure types and constants, no I/O'),
397
+ },
398
+ ...packageShapeFiles(app, 'domain', 'Pure types and constants, no I/O'),
399
+ { path: 'packages/domain/src/index.ts', contents: domainIndex() },
400
+ { path: 'packages/domain/src/index.test.ts', contents: domainTest() },
401
+ {
402
+ path: 'packages/db/package.json',
403
+ contents: domainPackage(app, 'db', 'Entity re-exports and SQL migrations, no business logic'),
404
+ },
405
+ ...packageShapeFiles(app, 'db', 'Entity re-exports and SQL migrations, no business logic'),
406
+ { path: 'packages/db/src/index.ts', contents: dbIndex() },
407
+ { path: 'packages/db/src/schema.ts', contents: dbSchema(app, example) },
408
+ { path: 'packages/db/src/seed.ts', contents: dbSeed(app, example) },
409
+ { path: 'packages/db/migrations/0000_initial.sql', contents: migration(example) },
410
+ ...i18nFiles(app, version),
411
+ {
412
+ path: 'packages/ui/package.json',
413
+ contents: domainPackage(app, 'ui', 'App components on @ultimat3/ui'),
414
+ },
415
+ ...packageShapeFiles(app, 'ui', 'App components on @ultimat3/ui'),
416
+ { path: 'packages/ui/src/index.ts', contents: uiIndex() },
417
+ { path: 'packages/ui/src/card.tsx', contents: uiCard() },
418
+ { path: 'packages/ui/src/card.module.scss', contents: uiCardStyle() },
419
+ {
420
+ path: 'packages/mcp/package.json',
421
+ contents: domainPackage(app, 'mcp', "The app's own MCP tools"),
422
+ },
423
+ ...packageShapeFiles(app, 'mcp', "The app's own MCP tools"),
424
+ { path: 'packages/mcp/src/index.ts', contents: mcpIndex(app) },
425
+ { path: 'packages/mcp/src/index.test.ts', contents: mcpTest() },
426
+ ];
427
+ }
@@ -0,0 +1,130 @@
1
+ // Decides which files `x test` runs: discovery, the `--filter`/type/`--sample` narrowing, and the
2
+ // validation behind the type positional and `--sample`. Selection only — nothing here splits a
3
+ // shard or spawns a process, so a wrong file list is always this file's bug and never a race.
4
+
5
+ // Bun ships no equivalent: `join` builds the host-separator path from the scan root to a hit.
6
+ // Sizing is Bun's own (`Bun.file().size`), so nothing here reaches for `node:fs`.
7
+ import { join } from 'node:path';
8
+ import { BadFlagError } from './errors';
9
+ import type { ParsedArgs } from './parse';
10
+ import { flagString, nearest } from './parse';
11
+ import type { TestType } from './verify-tests';
12
+ import { TEST_TYPES, typeFilterOf } from './verify-tests';
13
+
14
+ export interface TestFile {
15
+ readonly path: string;
16
+ readonly bytes: number;
17
+ }
18
+
19
+ const TEST_GLOB = '**/*.test.ts';
20
+
21
+ /**
22
+ * The root `test` script's ignore list, kept identical so `x test` and `bun run test` see one
23
+ * suite. `e2e/` is NOT on it: an opt-in suite that the gate runs but `x test` silently drops is
24
+ * a suite nobody runs until CI says so. `examples/` is, because the reference app is a separate
25
+ * project with its own gate — `x verify` there, not `x test` here.
26
+ */
27
+ const IGNORED = ['/dist/', '/node_modules/', '/examples/'];
28
+
29
+ /**
30
+ * File size stands in for duration: cheap to read, and it correlates far better than file count.
31
+ * `type`, when given, narrows to exactly the files verify-tests.ts would run for that suite — see
32
+ * `belongsToType` below, the one place that rule is decided.
33
+ */
34
+ export async function discoverTests(
35
+ root: string,
36
+ filter?: string,
37
+ type?: TestType,
38
+ ): Promise<readonly TestFile[]> {
39
+ const files: TestFile[] = [];
40
+ for await (const found of new Bun.Glob(TEST_GLOB).scan({ cwd: root, absolute: false })) {
41
+ const path = found.split('\\').join('/');
42
+ if (IGNORED.some((part) => `/${path}`.includes(part))) continue;
43
+ if (filter !== undefined && !path.includes(filter)) continue;
44
+ if (type !== undefined && !belongsToType(path, type)) continue;
45
+ files.push({ path, bytes: Bun.file(join(root, path)).size });
46
+ }
47
+ return files;
48
+ }
49
+
50
+ // Not localeCompare: its ordering depends on the machine's locale, and the split must not.
51
+ export const bySizeThenPath = (a: TestFile, b: TestFile): number =>
52
+ b.bytes - a.bytes || (a.path > b.path ? 1 : -1);
53
+
54
+ /**
55
+ * `--sample`'s deterministic slice, reusing the same (size desc, path asc) order `planShards`
56
+ * relies on for sharding — never a random sample, so a rerun keeps the same N files. It exists for
57
+ * the eval loop: that suite is the slowest one, and an agent iterating on a prompt needs a fast
58
+ * partial signal long before the full type finishes. That is exactly why a sampled run is NOT a
59
+ * gate — a pass over the first N files says nothing about the files left out, which is why the
60
+ * result names what actually ran (`cli.test.sampled`, `data.sample`) instead of a plain pass that
61
+ * reads like the whole type went green.
62
+ */
63
+ export function sampleFiles(files: readonly TestFile[], sample: number): readonly TestFile[] {
64
+ return [...files].sort(bySizeThenPath).slice(0, sample);
65
+ }
66
+
67
+ /**
68
+ * verify-tests.ts owns the one definition of what a file's test type is; `typeFilterOf` is that
69
+ * table's own accessor. Re-declaring the suffixes here would be a second definition, and the two
70
+ * would drift the first time a suite's naming rule changed.
71
+ */
72
+ const TYPE_FILTERS: readonly (readonly [Exclude<TestType, 'unit'>, string])[] = TEST_TYPES.filter(
73
+ (type): type is Exclude<TestType, 'unit'> => type !== 'unit',
74
+ ).map((type) => [type, typeFilterOf(type)] as const);
75
+
76
+ /** unit is everything the five typed suites do not claim, so no file falls between two types. */
77
+ export function belongsToType(path: string, type: TestType): boolean {
78
+ if (type === 'unit') return TYPE_FILTERS.every(([, filter]) => !path.includes(filter));
79
+ return TYPE_FILTERS.some(([typed, filter]) => typed === type && path.includes(filter));
80
+ }
81
+
82
+ /**
83
+ * The positional now means exactly one thing — one of the six test types — a deliberate breaking
84
+ * change from the old free-text filter positional. `undefined` means "every type," today's
85
+ * whole-suite behaviour, unchanged.
86
+ */
87
+ export function readType(raw: string | undefined): TestType | undefined {
88
+ if (raw === undefined) return undefined;
89
+ const known: readonly string[] = TEST_TYPES;
90
+ if (known.includes(raw)) return raw as TestType;
91
+ const suggestion = nearest(raw, known);
92
+ throw new BadFlagError({
93
+ flag: 'type',
94
+ command: 'test',
95
+ reason: `"${raw}" is not a test type (known: ${TEST_TYPES.join(', ')})`,
96
+ fix: suggestion === undefined ? `x test ${TEST_TYPES[0]}` : `x test ${suggestion}`,
97
+ });
98
+ }
99
+
100
+ /**
101
+ * `--sample` exists for the eval loop: it is the slowest suite, and an agent iterating on a prompt
102
+ * needs a fast partial signal long before the full type finishes. A sampled run is therefore NOT a
103
+ * gate — the caller has to be told it ran a subset, which is why a sampled result carries its own
104
+ * summary line and `data.sample` instead of quietly reporting a subset as if it were everything.
105
+ */
106
+ export function readSample(args: ParsedArgs): number | undefined {
107
+ const raw = flagString(args, 'sample');
108
+ if (raw === undefined) return undefined;
109
+ const value = /^\d+$/.test(raw) ? Number.parseInt(raw, 10) : Number.NaN;
110
+ if (!Number.isInteger(value) || value < 1) {
111
+ throw new BadFlagError({
112
+ flag: 'sample',
113
+ command: 'test',
114
+ reason: `expects an integer >= 1, got "${raw}"`,
115
+ fix: 'x test eval --sample 5',
116
+ });
117
+ }
118
+ return value;
119
+ }
120
+
121
+ /** The selection, as `NoTestFilesError` wants it: only the parts the caller actually asked for. */
122
+ export function missingSelection(
123
+ type: TestType | undefined,
124
+ filter: string | undefined,
125
+ ): { type?: TestType; filter?: string } {
126
+ return {
127
+ ...(type === undefined ? {} : { type }),
128
+ ...(filter === undefined ? {} : { filter }),
129
+ };
130
+ }