@ultimat3/cli 30.6.0 → 30.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -87,6 +87,7 @@ Commands: `bun test packages/cli` (from the repo root: the preload),
87
87
  | `app-name.ts` | `x new`'s slug is asked of core's `defineConfig`, never a copied pattern; refused with a suggested name (`9lives` → `app-9lives`, `a` → `a-app`, cut at 64) |
88
88
  | `generate-grants.ts` | a written `policy.ts` or `entity.ts` declares and grants its permissions in `apps/web/shared/roles.ts`. Full rule: `docs/history/cli.md` |
89
89
  | `api-registration.ts` | a written job or task is listed in `apps/web/api/index.ts` |
90
+ | `generate-exercise.ts` | a contract test's entry in `apps/web/exercise.ts` (`exercise-entry-edit.ts`) |
90
91
  | `handle-registration.ts` / `workspace-dep-edit.ts` / `templates/scaffold-db-client.ts` | a written entity joins `entities` in `packages/db/src/client.ts` (`X_DB_HANDLE_UNREGISTERED` carries the lines). Full rule: `docs/history/cli.md` |
91
92
  | `generated-imports.ts` | every sibling workspace a written file imports is declared in the manifest it landed under (`x g admin:page`'s `@<app>/i18n` in `apps/admin`) — `package-shape` would otherwise refuse the generator's own output (`X_WORKSPACE_DEP_UNDECLARED`) |
92
93
  | `admin-registration.ts` / `templates/admin-catalog.ts` / `source-edit.ts` | `--admin` wires the override it wrote into `admin.ts` (`X_ADMIN_RESOURCE_UNWIRED`) and the labels its screen reads. Full rule: `docs/history/cli.md` |
@@ -95,7 +96,7 @@ Commands: `bun test packages/cli` (from the repo root: the preload),
95
96
  | `generate-feature-required.ts` | `x g query`/`backfill` with no slice holding an `entity.ts` is `X_FEATURE_REQUIRED` |
96
97
  | `generate-sign-in*.ts` | `x g sign-in`: one flag, no name; OIDC swaps only `x new`'s bytes |
97
98
  | `generate-format-written.ts` | the app's Biome over the TypeScript a run created, best effort; `lint` judges |
98
- | `templates/scaffold-mcp-{json,endpoint,admin}.ts` | `.mcp.json` (`bunx @ultimat3/cli mcp serve`, never `bunx x`), the refusing `apps/web/mcp.ts` + `app/auth/agent-token.ts`, `apps/admin/mcp.ts`; `scaffold-agents-md.test.ts` resolves every command `AGENTS.md` names |
99
+ | `templates/scaffold-mcp-{json,endpoint,admin}.ts` | `.mcp.json` (`bunx @ultimat3/cli mcp serve`, never `bunx x`) and the refusing MCP endpoints. Full rule: `docs/history/cli.md` |
99
100
 
100
101
  ### Boot (`x dev`, the container)
101
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "30.6.0",
3
+ "version": "30.7.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -53,33 +53,33 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@babel/core": "^8.0.7",
56
- "@ultimat3/action": "30.6.0",
57
- "@ultimat3/admin": "30.6.0",
58
- "@ultimat3/ai": "30.6.0",
59
- "@ultimat3/auth": "30.6.0",
60
- "@ultimat3/cache": "30.6.0",
61
- "@ultimat3/core": "30.6.0",
62
- "@ultimat3/db": "30.6.0",
63
- "@ultimat3/entity": "30.6.0",
64
- "@ultimat3/flags": "30.6.0",
65
- "@ultimat3/http": "30.6.0",
66
- "@ultimat3/i18n": "30.6.0",
67
- "@ultimat3/jobs": "30.6.0",
68
- "@ultimat3/mail": "30.6.0",
69
- "@ultimat3/manifest": "30.6.0",
70
- "@ultimat3/mcp": "30.6.0",
71
- "@ultimat3/money": "30.6.0",
72
- "@ultimat3/notify": "30.6.0",
73
- "@ultimat3/policy": "30.6.0",
74
- "@ultimat3/pwa": "30.6.0",
75
- "@ultimat3/query": "30.6.0",
76
- "@ultimat3/realtime": "30.6.0",
77
- "@ultimat3/render": "30.6.0",
78
- "@ultimat3/schema": "30.6.0",
79
- "@ultimat3/seo": "30.6.0",
80
- "@ultimat3/storage": "30.6.0",
81
- "@ultimat3/testing": "30.6.0",
82
- "@ultimat3/time": "30.6.0",
56
+ "@ultimat3/action": "30.7.0",
57
+ "@ultimat3/admin": "30.7.0",
58
+ "@ultimat3/ai": "30.7.0",
59
+ "@ultimat3/auth": "30.7.0",
60
+ "@ultimat3/cache": "30.7.0",
61
+ "@ultimat3/core": "30.7.0",
62
+ "@ultimat3/db": "30.7.0",
63
+ "@ultimat3/entity": "30.7.0",
64
+ "@ultimat3/flags": "30.7.0",
65
+ "@ultimat3/http": "30.7.0",
66
+ "@ultimat3/i18n": "30.7.0",
67
+ "@ultimat3/jobs": "30.7.0",
68
+ "@ultimat3/mail": "30.7.0",
69
+ "@ultimat3/manifest": "30.7.0",
70
+ "@ultimat3/mcp": "30.7.0",
71
+ "@ultimat3/money": "30.7.0",
72
+ "@ultimat3/notify": "30.7.0",
73
+ "@ultimat3/policy": "30.7.0",
74
+ "@ultimat3/pwa": "30.7.0",
75
+ "@ultimat3/query": "30.7.0",
76
+ "@ultimat3/realtime": "30.7.0",
77
+ "@ultimat3/render": "30.7.0",
78
+ "@ultimat3/schema": "30.7.0",
79
+ "@ultimat3/seo": "30.7.0",
80
+ "@ultimat3/storage": "30.7.0",
81
+ "@ultimat3/testing": "30.7.0",
82
+ "@ultimat3/time": "30.7.0",
83
83
  "babel-preset-solid": "^1.9.16"
84
84
  }
85
85
  }
@@ -8,7 +8,7 @@ import { join } from 'node:path';
8
8
  import { describeEntities, registeredEntities } from '@ultimat3/entity';
9
9
  import { registeredSubjects } from '@ultimat3/jobs';
10
10
  import type { NamedActor } from '@ultimat3/policy';
11
- import type { ExerciseRecord, SeedEntity } from '@ultimat3/testing';
11
+ import type { ExercisePhaseMs, ExerciseRecord, SeedEntity } from '@ultimat3/testing';
12
12
  import {
13
13
  entitySeedWriter,
14
14
  planSweepLines,
@@ -26,8 +26,10 @@ import {
26
26
  crashFindingLines,
27
27
  EXERCISE_REPORT_FILE,
28
28
  exerciseFindings,
29
+ exercisePhaseLine,
29
30
  exerciseReport,
30
31
  renderExerciseTable,
32
+ slowestProofLines,
31
33
  } from './exercise-findings';
32
34
  import { readExerciseOverrides } from './exercise-overrides';
33
35
  import { entityTenantFindings } from './exercise-static';
@@ -79,6 +81,7 @@ async function exercise(
79
81
  const proved = registeredSubjects().filter(
80
82
  (subject) => only === undefined && !subjectStatic.some((f) => f.at === subject.entity),
81
83
  );
84
+ let pass: ExercisePhaseMs | undefined;
82
85
  const records: readonly ExerciseRecord[] = await withExerciseDatabase(root, ctx.env, (client) =>
83
86
  runExercisePass({
84
87
  entities,
@@ -87,8 +90,12 @@ async function exercise(
87
90
  ...(overrides === undefined ? {} : { overrides }),
88
91
  ...(only === undefined ? {} : { only }),
89
92
  roles: exerciseRoles,
93
+ onPhases: (ms) => {
94
+ pass = ms;
95
+ },
90
96
  }),
91
97
  );
98
+ const subjectStarted = performance.now();
92
99
  // The subject proof (`exercise-subject.ts`) on a scratch database of its own: the pass's rows
93
100
  // are committed, and a second seed graph beside them collides on every small unique domain.
94
101
  const subjectProofs =
@@ -97,7 +104,9 @@ async function exercise(
97
104
  : await withExerciseDatabase(root, ctx.env, (client) =>
98
105
  subjectProofFindings(proved, cores, client, overrides?.factories),
99
106
  );
100
- const report = exerciseReport(records, seed);
107
+ const subject = Math.round(performance.now() - subjectStarted);
108
+ const phases = pass === undefined ? undefined : { ...pass, subject };
109
+ const report = exerciseReport(records, seed, phases);
101
110
  // The whole pass, not a one-primitive run's slice of it, is what the file is for — but a
102
111
  // one-primitive run is still a fact worth keeping, so it is written either way.
103
112
  await Bun.write(join(root, EXERCISE_REPORT_FILE), `${JSON.stringify(report, null, 2)}\n`);
@@ -119,6 +128,8 @@ async function exercise(
119
128
  : msg('cli.exercise.summary', { ...report.counts, file: EXERCISE_REPORT_FILE }),
120
129
  lines: [
121
130
  ...renderExerciseTable(records),
131
+ ...(phases === undefined ? [] : [exercisePhaseLine(phases)]),
132
+ ...slowestProofLines(records),
122
133
  ...tenantFindingLines(records),
123
134
  ...crashFindingLines(records),
124
135
  ...planSweepLines(records),
@@ -14,6 +14,7 @@ import { invocationOf } from './command';
14
14
  import { localiseCatalogs, namedOrAppLocales } from './generate-catalog-locales';
15
15
  import { refuseChartAlertsFlags, runChartAlerts } from './generate-chart-alerts';
16
16
  import { appDisk, plannedDisk } from './generate-disk';
17
+ import { writtenExercises } from './generate-exercise';
17
18
  import { assertFeatureExists } from './generate-feature';
18
19
  import { assertSliceToRead } from './generate-feature-required';
19
20
  import { generate, sliceDir } from './generate-files';
@@ -195,7 +196,13 @@ export const generateCommand: CliCommand = {
195
196
  const follow =
196
197
  refused.length > 0
197
198
  ? { edited: [], findings: [] }
198
- : await followUpEdits(root, plan.written, dbModule, plannedDisk(root, planned));
199
+ : await followUpEdits(
200
+ root,
201
+ plan.written,
202
+ dbModule,
203
+ plannedDisk(root, planned),
204
+ writtenExercises(files, plan.written),
205
+ );
199
206
  const findings = [...refused, ...follow.findings];
200
207
  const touched = refused.length === 0 ? [...plan.written, ...follow.edited] : [];
201
208
  // Re-projected after a real write, from the loaded app: named, since their bytes are not
@@ -228,7 +235,13 @@ export const generateCommand: CliCommand = {
228
235
  // The edits a generated primitive needs outside its own slice, performed rather than left as
229
236
  // findings (`generate-follow-ups.ts`). Before the manifest load below, so the projection sees
230
237
  // them all — a repo reading a table the handle lacks would not load at all.
231
- const follow = await followUpEdits(root, report.written, dbModule, appDisk(root));
238
+ const follow = await followUpEdits(
239
+ root,
240
+ report.written,
241
+ dbModule,
242
+ appDisk(root),
243
+ writtenExercises(files, report.written),
244
+ );
232
245
  const edited = follow.edited;
233
246
  // Before the manifest reads a byte of it. Only what this run created: an edited file is its
234
247
  // author's, and a run that reformatted one would hand them a diff they never asked for.
package/src/cmd-new.ts CHANGED
@@ -12,6 +12,7 @@ import type { CliCommand, CommandContext } from './command';
12
12
  import { invocationOf } from './command';
13
13
  import { AppNameEmptyError, AppNameIsPathError, MissingPositionalError } from './errors';
14
14
  import type { Runner } from './exec';
15
+ import { withDeclaredExercises } from './generate-exercise';
15
16
  import { reproducedFlags } from './invocation-flags';
16
17
  import { msg } from './messages';
17
18
  import { type NewShape, readShape } from './new-shapes';
@@ -142,7 +143,7 @@ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
142
143
  const overlay = SHAPE_OVERLAYS[options.shape];
143
144
  const base = planNewApp({ name: options.name, example: overlay.example });
144
145
  // Merge-aware, as below: a shape's catalog keys land in the one catalog the base wrote.
145
- return dedupe(applyOverlay(base, overlay, names(options.name)));
146
+ return withDeclaredExercises(dedupe(applyOverlay(base, overlay, names(options.name))));
146
147
  }
147
148
  const app = names(options.name);
148
149
  const files: GeneratedFile[] = [
@@ -169,7 +170,8 @@ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
169
170
  // `repoFiles`' own catalog entry and the example resource's both target the same flat catalog
170
171
  // file, so this has to be the merge-aware dedupe — the one `cmd-generate.ts` uses for `x g` —
171
172
  // or the second contributor's keys would silently vanish instead of landing in the one file.
172
- return dedupe(files);
173
+ // And each contract test's entry in `apps/web/exercise.ts`, the one file it reads it from.
174
+ return withDeclaredExercises(dedupe(files));
173
175
  }
174
176
 
175
177
  export interface WrittenApp {
@@ -250,6 +250,7 @@ export const CLI_OWNED_ERROR_CODES = [
250
250
  'X_NEW_SHAPE_GENERATE_FAILED',
251
251
  'X_ISLAND_DISPOSER_MISSING',
252
252
  'X_SOCKET_HAND_ROLLED',
253
+ 'X_EXERCISE_FILE_UNEDITABLE',
253
254
  ] as const;
254
255
 
255
256
  /**
@@ -470,6 +471,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliListedErrorCode, string>> = {
470
471
  X_NEW_SHAPE_GENERATE_FAILED: 'a shape generator failed in the new app',
471
472
  X_ISLAND_DISPOSER_MISSING: "an island's mount returns no disposer",
472
473
  X_SOCKET_HAND_ROLLED: 'an app upgrades a websocket itself',
474
+ X_EXERCISE_FILE_UNEDITABLE: 'x g could not add an entry to apps/web/exercise.ts',
473
475
  };
474
476
 
475
477
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -0,0 +1,169 @@
1
+ // The one editor of an app's `apps/web/exercise.ts`: entries inserted into its single
2
+ // `export const exercise = defineExercise({ … })`, read off the MASKED source (a call in a comment
3
+ // or a string is prose), printed the way Biome leaves them, each import in Biome's order. A file
4
+ // it cannot read that way is refused with the reason, never guessed at; a named entry is the app's.
5
+
6
+ import { maskOf, readList, readProperties } from './source-list-edit';
7
+ import { exerciseFiles } from './templates/scaffold-exercise';
8
+ import { wrapImport } from './templates/wrap';
9
+
10
+ /** One value an entry needs imported: `userActor` from `@ultimat3/core`. */
11
+ export interface ExerciseImport {
12
+ readonly name: string;
13
+ readonly from: string;
14
+ }
15
+
16
+ /**
17
+ * One primitive's entry. `value` is its object literal printed at column 0 (`{\n actor: …,\n}` or
18
+ * `{ off: '…' }`); the edit indents it to where the call's properties sit.
19
+ */
20
+ export interface ExerciseEntry {
21
+ readonly name: string;
22
+ readonly value: string;
23
+ readonly imports?: readonly ExerciseImport[];
24
+ }
25
+
26
+ export type ExerciseEdit =
27
+ | { readonly kind: 'edited' | 'unchanged'; readonly source: string }
28
+ | { readonly kind: 'refused'; readonly reason: string };
29
+
30
+ const CALL = /\bdefineExercise\s*\(\s*/g;
31
+ const EXPORTED = /\bexport\s+const\s+exercise\s*=\s*defineExercise\s*\(/;
32
+ /** An entry's key: `name:`, `'name':`, or a shorthand `name` — read off the entry's own text. */
33
+ const KEY = /^(?:([A-Za-z_$][\w$]*)|'([^']*)'|"([^"]*)")\s*(?::|$)/;
34
+
35
+ const refused = (reason: string): ExerciseEdit => ({ kind: 'refused', reason });
36
+
37
+ /** `source` (or `x new`'s file when there is none) with every entry it does not name yet. */
38
+ export function editExerciseFile(
39
+ source: string | undefined,
40
+ entries: readonly ExerciseEntry[],
41
+ ): ExerciseEdit {
42
+ const base = source ?? String(exerciseFiles()[0]?.contents ?? '');
43
+ const masked = maskOf(base);
44
+ const calls = [...masked.matchAll(CALL)];
45
+ const call = calls[0];
46
+ if (call === undefined) return refused('holds no defineExercise() call');
47
+ if (calls.length > 1) return refused(`holds ${calls.length} defineExercise() calls`);
48
+ const open = call.index + call[0].length;
49
+ if (masked[open] !== '{') {
50
+ return refused('hands defineExercise() something other than an object literal ({ … })');
51
+ }
52
+ if (!EXPORTED.test(masked)) {
53
+ return refused('does not declare it as export const exercise = defineExercise(…)');
54
+ }
55
+ const list = readList(base, masked, open);
56
+ if (list === undefined) return refused('has a defineExercise({ … }) that never closes');
57
+ const named = new Set(list.entries.map((entry) => keyOf(entry.text)));
58
+ const missing = unique(entries).filter((entry) => !named.has(entry.name));
59
+ if (missing.length === 0) return { kind: 'unchanged', source: base };
60
+
61
+ const inserted = insertRows(base, masked, list, missing);
62
+ const next = withImports(
63
+ inserted,
64
+ missing.flatMap((entry) => entry.imports ?? []),
65
+ );
66
+ return readsBack(next, missing)
67
+ ? { kind: 'edited', source: next }
68
+ : refused('reads back changed');
69
+ }
70
+
71
+ const keyOf = (text: string): string | undefined => {
72
+ const found = KEY.exec(text);
73
+ return found === null ? undefined : (found[1] ?? found[2] ?? found[3]);
74
+ };
75
+
76
+ const unique = (entries: readonly ExerciseEntry[]): readonly ExerciseEntry[] =>
77
+ entries.filter((entry, index) => entries.findIndex((e) => e.name === entry.name) === index);
78
+
79
+ const lineIndent = (source: string, at: number): string => {
80
+ const start = source.lastIndexOf('\n', at - 1) + 1;
81
+ return /^[ \t]*/.exec(source.slice(start))?.[0] ?? '';
82
+ };
83
+
84
+ /** ` name: value,` with every line of the value under `indent`. */
85
+ const row = (indent: string, entry: ExerciseEntry): string =>
86
+ `${indent}${entry.name}: ${entry.value.split('\n').join(`\n${indent}`)},`;
87
+
88
+ function insertRows(
89
+ source: string,
90
+ masked: string,
91
+ list: NonNullable<ReturnType<typeof readList>>,
92
+ entries: readonly ExerciseEntry[],
93
+ ): string {
94
+ const outer = lineIndent(source, list.open);
95
+ const multiline = masked.slice(list.open, list.close).includes('\n');
96
+ const first = list.entries[0];
97
+ // A property's own indent where one starts its line; else one step in from the brace's line.
98
+ const startsLine =
99
+ first !== undefined &&
100
+ source.slice(source.lastIndexOf('\n', first.start - 1) + 1, first.start).trim() === '';
101
+ const indent =
102
+ multiline && startsLine && first !== undefined ? lineIndent(source, first.start) : `${outer} `;
103
+ const rows = entries.map((entry) => row(indent, entry));
104
+ const closeLine = source.lastIndexOf('\n', list.close) + 1;
105
+ const last = list.entries.at(-1);
106
+ if (multiline && source.slice(closeLine, list.close).trim() === '') {
107
+ // Appended as the last rows, above the closing brace's own line; the comma the old last entry
108
+ // owes goes right after its code, before any comment beside it.
109
+ const before = source.slice(0, closeLine);
110
+ const head =
111
+ last === undefined || list.trailingComma
112
+ ? before
113
+ : `${before.slice(0, last.end)},${before.slice(last.end)}`;
114
+ return `${head}${rows.join('\n')}\n${source.slice(closeLine)}`;
115
+ }
116
+ // One line — `{}` or `{ keep: … }` — or a brace sharing its line with code: a row each, Biome's
117
+ // expanded shape, the old entries first.
118
+ const kept = list.entries.map((entry) => `${indent}${entry.text},`);
119
+ const head = source.slice(0, list.open + 1);
120
+ return `${head}\n${[...kept, ...rows].join('\n')}\n${outer}${source.slice(list.close)}`;
121
+ }
122
+
123
+ /** Every top-level `import … from '<x>';` of the source: where it spans and what it names. */
124
+ const IMPORT = /^import\s+(type\s+)?\{([^}]*)\}\s*from\s*'([^']+)';[^\S\n]*$/gm;
125
+ const ANY_IMPORT = /^import\s[^;]*?from\s*'([^']+)';/gm;
126
+
127
+ /** Biome's groups, coarsely: builtins, then packages, then relative paths. */
128
+ const rank = (from: string): number =>
129
+ /^(?:bun|node):/.test(from) ? 0 : from.startsWith('.') ? 2 : 1;
130
+
131
+ const before = (left: string, right: string): boolean =>
132
+ rank(left) !== rank(right) ? rank(left) < rank(right) : left < right;
133
+
134
+ function withImports(source: string, wanted: readonly ExerciseImport[]): string {
135
+ let next = source;
136
+ for (const want of wanted) {
137
+ const masked = maskOf(next);
138
+ const statements = [...next.matchAll(IMPORT)].filter((m) => masked[m.index] === 'i');
139
+ const named = statements.find((m) => m[1] === undefined && m[3] === want.from);
140
+ if (named !== undefined) {
141
+ const names = (named[2] ?? '')
142
+ .split(',')
143
+ .map((name) => name.trim())
144
+ .filter(Boolean);
145
+ if (names.includes(want.name)) continue;
146
+ const line = wrapImport([...names, want.name], want.from);
147
+ next = `${next.slice(0, named.index)}${line}${next.slice(named.index + named[0].length)}`;
148
+ continue;
149
+ }
150
+ const all = [...next.matchAll(ANY_IMPORT)].filter((m) => masked[m.index] === 'i');
151
+ const after = all.find((m) => before(want.from, m[1] ?? ''));
152
+ const last = all.at(-1);
153
+ const at = after?.index ?? (last === undefined ? 0 : last.index + last[0].length + 1);
154
+ next = `${next.slice(0, at)}${wrapImport([want.name], want.from)}\n${next.slice(at)}`;
155
+ }
156
+ return next;
157
+ }
158
+
159
+ /** One call still, and every inserted name a key of it now. */
160
+ function readsBack(source: string, entries: readonly ExerciseEntry[]): boolean {
161
+ const masked = maskOf(source);
162
+ const calls = [...masked.matchAll(CALL)];
163
+ const call = calls[0];
164
+ if (calls.length !== 1 || call === undefined) return false;
165
+ const keys = new Set(
166
+ readProperties(source, masked, call.index + call[0].length).map((p) => p.key),
167
+ );
168
+ return entries.every((entry) => keys.has(entry.name));
169
+ }
@@ -11,8 +11,9 @@ import {
11
11
  renderFixLiteral,
12
12
  renderFixShellArg,
13
13
  } from '@ultimat3/core';
14
- import type { ExerciseRecord } from '@ultimat3/testing';
14
+ import type { ExercisePhaseMs, ExerciseRecord, ProofMs } from '@ultimat3/testing';
15
15
  import { crashEffectPlace, planSweepFindings, tenantProofFindings } from '@ultimat3/testing';
16
+ import { msg } from './messages';
16
17
  import type { Finding } from './output';
17
18
  import { renderTable } from './table';
18
19
 
@@ -73,7 +74,7 @@ const unreachedFinding = (record: ExerciseRecord): Finding => {
73
74
  cause: `${record.kind} ${renderCauseValue(record.name)} never reached its handler as an allowed actor in the exercise pass${stopped}${why}${defectOf(record)}`,
74
75
  fix:
75
76
  record.fix ??
76
- `defineExercise({ ${override} }) # paste the entry into ${EXERCISE_FILE}, or { off: '<why>' }; then: x exercise ${name} --json`,
77
+ `defineExercise({ ${override} }) # paste the entry into ${EXERCISE_FILE} (an inline .contract({ exercise: {...} }) in a test is not read here: move it, then pass .contract({ exercise })), or { off: '<why>' }; then: x exercise ${name} --json`,
77
78
  docs: ERROR_DOCS_URL,
78
79
  at: EXERCISE_FILE,
79
80
  };
@@ -140,23 +141,89 @@ export const exerciseFindings = (records: readonly ExerciseRecord[]): readonly F
140
141
  ...planSweepFindings(records).map((one) => ({ ...one, docs: ERROR_DOCS_URL })),
141
142
  ];
142
143
 
144
+ /** The pass's phases, and the subject proof the CLI runs after it on a database of its own. */
145
+ export interface ExercisePhases extends ExercisePhaseMs {
146
+ readonly subject: number;
147
+ }
148
+
143
149
  export interface ExerciseReport {
144
150
  readonly seed: number | null;
145
151
  readonly counts: ExerciseCounts;
152
+ /** Milliseconds per phase: absent only when the pass threw before it could say. */
153
+ readonly phases?: ExercisePhases;
146
154
  readonly records: readonly ExerciseRecord[];
147
155
  }
148
156
 
149
157
  export const exerciseReport = (
150
158
  records: readonly ExerciseRecord[],
151
159
  seed: number | undefined,
152
- ): ExerciseReport => ({ seed: seed ?? null, counts: exerciseCounts(records), records });
160
+ phases?: ExercisePhases,
161
+ ): ExerciseReport => ({
162
+ seed: seed ?? null,
163
+ counts: exerciseCounts(records),
164
+ ...(phases === undefined ? {} : { phases }),
165
+ records,
166
+ });
167
+
168
+ const PHASE_ORDER = ['seed', 'primitives', 'scale', 'plans', 'subject'] as const;
169
+
170
+ /** `took 10610 ms — seed 800 · primitives 9100 · … — slowest: primitives`. */
171
+ export function exercisePhaseLine(phases: ExercisePhases): string {
172
+ const total = PHASE_ORDER.reduce((sum, key) => sum + phases[key], 0);
173
+ const slowest = PHASE_ORDER.reduce((top, key) => (phases[key] > phases[top] ? key : top));
174
+ return msg('cli.exercise.phases', {
175
+ total,
176
+ phases: PHASE_ORDER.map((key) => `${key} ${phases[key]}`).join(' · '),
177
+ slowest,
178
+ });
179
+ }
180
+
181
+ const PROOF_ORDER = ['tenant', 'crash', 'scale'] as const satisfies readonly (keyof ProofMs)[];
182
+
183
+ /** How many records the slowest-proof lines name: the head of the list is where the time went. */
184
+ export const SLOWEST_SHOWN = 5;
185
+
186
+ /** The records whose one proof took longest, slowest first: `slowest: job x · crash 9000 ms`. */
187
+ export function slowestProofLines(records: readonly ExerciseRecord[]): readonly string[] {
188
+ const proofs = records.flatMap((record) => {
189
+ const top = PROOF_ORDER.map((proof) => [proof, record.proofMs?.[proof] ?? 0] as const).reduce(
190
+ (best, one) => (one[1] > best[1] ? one : best),
191
+ );
192
+ return top[1] > 0 ? [{ record, proof: top[0], ms: top[1] }] : [];
193
+ });
194
+ return proofs
195
+ .toSorted((left, right) => right.ms - left.ms)
196
+ .slice(0, SLOWEST_SHOWN)
197
+ .map((one) =>
198
+ msg('cli.exercise.slowest', {
199
+ primitive: `${one.record.kind} ${one.record.name}`,
200
+ proof: one.proof,
201
+ ms: one.ms,
202
+ }),
203
+ );
204
+ }
205
+
206
+ const msOf = (value: number | undefined): string => (value === undefined ? '-' : String(value));
153
207
 
154
208
  /** Unreached first — the rows that need a hand — then refused, off, reached; by name within. */
155
209
  const RANK: readonly Outcome[] = ['unreached', 'refused', 'off', 'reached'];
156
210
 
157
211
  export const renderExerciseTable = (records: readonly ExerciseRecord[]): readonly string[] =>
158
212
  renderTable(
159
- ['primitive', 'kind', 'outcome', 'tenant', 'crash', 'code', 'statements', 'effects', 'ms'],
213
+ [
214
+ 'primitive',
215
+ 'kind',
216
+ 'outcome',
217
+ 'tenant',
218
+ 'crash',
219
+ 'code',
220
+ 'statements',
221
+ 'effects',
222
+ 'ms',
223
+ 'tenant ms',
224
+ 'crash ms',
225
+ 'scale ms',
226
+ ],
160
227
  records
161
228
  .toSorted(
162
229
  (left, right) =>
@@ -173,5 +240,8 @@ export const renderExerciseTable = (records: readonly ExerciseRecord[]): readonl
173
240
  String(one.statements),
174
241
  String(one.effects.length),
175
242
  String(one.ms),
243
+ msOf(one.proofMs?.tenant),
244
+ msOf(one.proofMs?.crash),
245
+ msOf(one.proofMs?.scale),
176
246
  ]),
177
247
  );
@@ -2,6 +2,7 @@
2
2
  // cannot run as generated, each turned off with its reason, so the app's next `x verify` is green.
3
3
  // An entry the app already declares is the app's and is never touched.
4
4
 
5
+ import { editExerciseFile } from './exercise-entry-edit';
5
6
  import { exerciseFiles } from './templates/scaffold-exercise';
6
7
 
7
8
  export const EXERCISE_FILE = 'apps/web/exercise.ts';
@@ -30,27 +31,22 @@ export const signInExerciseEntries = (
30
31
  return entry === undefined ? [] : [entry];
31
32
  });
32
33
 
33
- const DEFINE = 'defineExercise({';
34
-
35
34
  /**
36
35
  * `source` with each entry not already named in it inserted at the top of `defineExercise({ … })`;
37
- * `undefined` when there is no file, which starts from `x new`'s. A file with no `defineExercise({`
38
- * is the app's own shape and is left as it is.
36
+ * `undefined` when there is no file, which starts from `x new`'s. A file the one editor
37
+ * (`exercise-entry-edit.ts`) cannot read is the app's own shape and is left as it is.
39
38
  */
40
39
  export function exerciseWithEntries(
41
40
  source: string | undefined,
42
41
  entries: readonly (readonly [string, string])[],
43
42
  ): string {
44
- const base = source ?? String(exerciseFiles()[0]?.contents ?? '');
45
- const at = base.indexOf(DEFINE);
46
- if (at === -1) return base;
47
- const missing = entries.filter(([name]) => !new RegExp(`\\b${name}\\s*:`).test(base));
48
- if (missing.length === 0) return base;
49
- const lines = missing.map(([name, why]) => ` ${name}: { off: ${singleQuoted(why)} },`);
50
- const after = at + DEFINE.length;
51
- // `defineExercise({})` — the scaffold's — opens onto its own close: the close gets its own line.
52
- const rest = base.slice(after);
53
- return `${base.slice(0, after)}\n${lines.join('\n')}${rest.startsWith('}') ? '\n' : ''}${rest}`;
43
+ const edit = editExerciseFile(
44
+ source,
45
+ entries.map(([name, why]) => ({ name, value: `{ off: ${singleQuoted(why)} }` })),
46
+ );
47
+ return edit.kind === 'refused'
48
+ ? (source ?? String(exerciseFiles()[0]?.contents ?? ''))
49
+ : edit.source;
54
50
  }
55
51
 
56
52
  /** A single-quoted TypeScript string literal, Biome's own quote style. */
@@ -0,0 +1,77 @@
1
+ // `x g`'s half of the one exercise declaration: each written contract test's entry inserted into
2
+ // `apps/web/exercise.ts` (created from `x new`'s shape when the app has none), since the test reads
3
+ // it from there. A file the editor cannot read is `X_EXERCISE_FILE_UNEDITABLE`, the entry in its fix.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+ import type { ExerciseEntry } from './exercise-entry-edit';
7
+ import { editExerciseFile } from './exercise-entry-edit';
8
+ import { EXERCISE_FILE } from './exercise-registration';
9
+ import type { GenerateDisk } from './generate-disk';
10
+ import type { Finding } from './output';
11
+ import type { GeneratedFile } from './templates';
12
+
13
+ /** The entries the files this run WROTE carry — a skipped or conflicting file is owed none. */
14
+ export const writtenExercises = (
15
+ files: readonly GeneratedFile[],
16
+ written: readonly string[],
17
+ ): readonly ExerciseEntry[] =>
18
+ files.flatMap((file) =>
19
+ 'exercise' in file && file.exercise !== undefined && written.includes(file.path)
20
+ ? [file.exercise]
21
+ : [],
22
+ );
23
+
24
+ /** One line of an entry: what the fix asks to paste, whitespace folded. */
25
+ const oneLine = (entry: ExerciseEntry): string =>
26
+ `${entry.name}: ${entry.value.replace(/\s*\n\s*/g, ' ').replace(/, }/g, ' }')}`;
27
+
28
+ /** Inserts `entries` into the app's exercise file; answers the path when it changed, or a refusal. */
29
+ export async function declareGeneratedExercises(
30
+ entries: readonly ExerciseEntry[],
31
+ disk: GenerateDisk,
32
+ ): Promise<{ readonly edited: readonly string[]; readonly findings: readonly Finding[] }> {
33
+ if (entries.length === 0) return { edited: [], findings: [] };
34
+ const before = await disk.read(EXERCISE_FILE);
35
+ const edit = editExerciseFile(before, entries);
36
+ if (edit.kind === 'refused') {
37
+ return {
38
+ edited: [],
39
+ findings: [
40
+ {
41
+ code: 'X_EXERCISE_FILE_UNEDITABLE',
42
+ cause: `${EXERCISE_FILE} ${edit.reason}, so x g could not declare ${entries.map((e) => e.name).join(', ')} there and its contract test reads no entry`,
43
+ // The code to paste first: a fix is pasted from its first character.
44
+ fix: `${entries.map(oneLine).join(', ')}, # paste inside export const exercise = defineExercise({ … }) in ${EXERCISE_FILE}`,
45
+ at: EXERCISE_FILE,
46
+ },
47
+ ],
48
+ };
49
+ }
50
+ if (edit.kind === 'unchanged' && before !== undefined) return { edited: [], findings: [] };
51
+ await disk.write(EXERCISE_FILE, edit.source);
52
+ return { edited: [EXERCISE_FILE], findings: [] };
53
+ }
54
+
55
+ /**
56
+ * `x new`'s file set with every contract test's entry already in its `apps/web/exercise.ts` — the
57
+ * scaffold writes both at once, so there is no disk to edit. The file is the template's own, so a
58
+ * refusal is this package's defect and is thrown, never written.
59
+ */
60
+ export function withDeclaredExercises(files: readonly GeneratedFile[]): readonly GeneratedFile[] {
61
+ const entries = writtenExercises(
62
+ files,
63
+ files.map((file) => file.path),
64
+ );
65
+ const at = files.findIndex((file) => file.path === EXERCISE_FILE);
66
+ const file = files[at];
67
+ if (entries.length === 0 || file === undefined || typeof file.contents !== 'string') return files;
68
+ const edit = editExerciseFile(file.contents, entries);
69
+ if (edit.kind === 'refused') {
70
+ throw new UltimateError({
71
+ code: 'X_EXERCISE_FILE_UNEDITABLE',
72
+ cause: `x new's own ${EXERCISE_FILE} ${edit.reason}, so its contract tests would read no entry`,
73
+ fix: 'bun test packages/cli/src/generate-exercise.test.ts # the scaffold template and the editor disagree',
74
+ });
75
+ }
76
+ return files.map((one, index) => (index === at ? { ...one, contents: edit.source } : one));
77
+ }