@ultimat3/cli 22.15.0 → 23.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 (169) hide show
  1. package/CLAUDE.md +23 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +25 -1
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-doctor.ts +35 -11
  20. package/src/cmd-generate.ts +118 -17
  21. package/src/cmd-jobs-spec.ts +19 -3
  22. package/src/cmd-jobs.ts +120 -3
  23. package/src/cmd-new.ts +15 -1
  24. package/src/cmd-routes.ts +15 -1
  25. package/src/cmd-secrets-ring.ts +76 -0
  26. package/src/cmd-secrets-spec.ts +10 -2
  27. package/src/cmd-secrets.ts +48 -12
  28. package/src/cmd-tasks.ts +84 -15
  29. package/src/cmd-verify.ts +10 -2
  30. package/src/comment-safe.ts +16 -0
  31. package/src/coverage-floor.ts +334 -0
  32. package/src/coverage-lcov.ts +193 -0
  33. package/src/coverage-source.ts +136 -0
  34. package/src/db-object-drift.ts +30 -0
  35. package/src/db-replay-engine.ts +115 -0
  36. package/src/db-schema-dump.ts +150 -0
  37. package/src/db-schema-refresh.ts +122 -0
  38. package/src/dev-dashboard.ts +2 -2
  39. package/src/dev-route-table.ts +9 -0
  40. package/src/doctor-coverage.ts +39 -0
  41. package/src/doctor-guards.ts +54 -0
  42. package/src/doctor-sealed.ts +132 -0
  43. package/src/document-graph.ts +82 -0
  44. package/src/error-codes.ts +32 -0
  45. package/src/framework-schema.ts +15 -0
  46. package/src/generate-files.ts +76 -10
  47. package/src/generate-grant-findings.ts +76 -0
  48. package/src/generate-grants.ts +77 -3
  49. package/src/generate-kinds.ts +19 -0
  50. package/src/generate-write.ts +15 -27
  51. package/src/generated-imports.ts +59 -0
  52. package/src/guard-sources.ts +104 -0
  53. package/src/guards.ts +23 -7
  54. package/src/handle-registration.ts +217 -0
  55. package/src/i18n-registration.ts +36 -0
  56. package/src/image-prepare.ts +51 -8
  57. package/src/import-closure.ts +165 -0
  58. package/src/index.ts +11 -2
  59. package/src/island-store.ts +24 -16
  60. package/src/jobs-drain.ts +14 -7
  61. package/src/jobs-json.ts +50 -0
  62. package/src/jobs-report.ts +3 -0
  63. package/src/json-merge.ts +46 -0
  64. package/src/mcp-errors.ts +29 -1
  65. package/src/messages.ts +33 -0
  66. package/src/metrics-endpoint.ts +71 -24
  67. package/src/migration-extensions.ts +29 -0
  68. package/src/module-imports.ts +47 -0
  69. package/src/output.ts +18 -9
  70. package/src/permission-grants.ts +41 -9
  71. package/src/publish-closure.ts +101 -0
  72. package/src/role-load.ts +143 -0
  73. package/src/role-start.ts +18 -1
  74. package/src/role-wake.ts +45 -0
  75. package/src/runtime-admin.ts +141 -0
  76. package/src/runtime-hooks.ts +5 -2
  77. package/src/runtime-queue.ts +8 -1
  78. package/src/runtime-render.ts +6 -0
  79. package/src/runtime-services.ts +2 -1
  80. package/src/scaffold-typecheck.ts +1 -0
  81. package/src/schema-dump-drift.ts +64 -0
  82. package/src/serve-boot.ts +85 -157
  83. package/src/serve-prebuilt-paths.ts +19 -0
  84. package/src/serve-prebuilt.ts +84 -0
  85. package/src/serve-web.ts +164 -0
  86. package/src/serve.ts +6 -9
  87. package/src/server-barrels.ts +54 -0
  88. package/src/source-edit.ts +49 -0
  89. package/src/tasks-facts.ts +4 -1
  90. package/src/templates/action.ts +144 -11
  91. package/src/templates/admin-catalog.ts +58 -0
  92. package/src/templates/admin-page.ts +36 -8
  93. package/src/templates/admin.ts +57 -17
  94. package/src/templates/backfill.ts +121 -87
  95. package/src/templates/entity.ts +140 -44
  96. package/src/templates/guard-animated-layout-property.ts +4 -11
  97. package/src/templates/guard-bare-error.ts +9 -13
  98. package/src/templates/guard-focus-visible.ts +4 -11
  99. package/src/templates/guard-image-dimensions.ts +6 -6
  100. package/src/templates/guard-island-without-states.ts +2 -4
  101. package/src/templates/guard-raw-breakpoint.ts +210 -0
  102. package/src/templates/guard-raw-colour.ts +4 -11
  103. package/src/templates/guard-raw-length.ts +375 -0
  104. package/src/templates/guard-raw-motion.ts +213 -0
  105. package/src/templates/guard-raw-shadow.ts +187 -0
  106. package/src/templates/guard-raw-z-index.ts +166 -0
  107. package/src/templates/guard-repo-raw-sql.ts +345 -0
  108. package/src/templates/guard-semantic-interactive.ts +6 -6
  109. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  110. package/src/templates/guard-undefined-style-class.ts +215 -0
  111. package/src/templates/guard-untranslated-string.ts +6 -6
  112. package/src/templates/guard-unzoned-date.ts +9 -13
  113. package/src/templates/index.ts +3 -2
  114. package/src/templates/island.ts +34 -51
  115. package/src/templates/job-body-test.ts +121 -0
  116. package/src/templates/job.ts +23 -8
  117. package/src/templates/policy.ts +16 -9
  118. package/src/templates/query.ts +242 -26
  119. package/src/templates/resource-create.ts +62 -18
  120. package/src/templates/resource-form-island.ts +58 -80
  121. package/src/templates/resource-page.ts +269 -0
  122. package/src/templates/resource-service.ts +96 -0
  123. package/src/templates/resource.ts +86 -61
  124. package/src/templates/route.ts +38 -29
  125. package/src/templates/scaffold-api.ts +55 -1
  126. package/src/templates/scaffold-app.ts +171 -51
  127. package/src/templates/scaffold-auth.ts +83 -19
  128. package/src/templates/scaffold-browser-client.ts +90 -0
  129. package/src/templates/scaffold-container.ts +16 -0
  130. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  131. package/src/templates/scaffold-dashboard-example.ts +124 -31
  132. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  133. package/src/templates/scaffold-dashboard.ts +1 -2
  134. package/src/templates/scaffold-db-client.ts +142 -0
  135. package/src/templates/scaffold-db-package.ts +90 -8
  136. package/src/templates/scaffold-docs.ts +50 -5
  137. package/src/templates/scaffold-guards.ts +54 -11
  138. package/src/templates/scaffold-http.ts +9 -7
  139. package/src/templates/scaffold-repo.ts +52 -2
  140. package/src/templates/scaffold-roles.ts +56 -9
  141. package/src/templates/scaffold-shell.ts +36 -70
  142. package/src/templates/scaffold-site.ts +45 -19
  143. package/src/templates/scaffold-ui-package.ts +18 -0
  144. package/src/templates/slice-foundation.ts +46 -8
  145. package/src/test-counts.ts +18 -2
  146. package/src/transport-calls.ts +105 -0
  147. package/src/unscanned-admin.ts +54 -0
  148. package/src/verify-checks.ts +26 -7
  149. package/src/verify-coverage-run.ts +190 -0
  150. package/src/verify-deadline.ts +256 -0
  151. package/src/verify-errors.ts +15 -6
  152. package/src/verify-floor.ts +36 -9
  153. package/src/verify-merge.ts +117 -24
  154. package/src/verify-progress.ts +25 -0
  155. package/src/verify-role-load-probe.ts +54 -0
  156. package/src/verify-role-load.ts +148 -0
  157. package/src/verify-run.ts +93 -35
  158. package/src/verify-shard.ts +20 -9
  159. package/src/verify-step.ts +42 -1
  160. package/src/verify-tests.ts +78 -6
  161. package/src/workspace-checks.ts +50 -8
  162. package/src/workspace-dep-edit.ts +127 -0
  163. package/src/cmd-dev-fixture.ts +0 -204
  164. package/src/dev-live-fixture.ts +0 -122
  165. package/src/policy-fixture.ts +0 -78
  166. package/src/realtime-browser-probe-fixture.ts +0 -9
  167. package/src/role-start-fixture.ts +0 -78
  168. package/src/scaffold-fixture.ts +0 -160
  169. package/src/templates/island-fixture.ts +0 -76
package/src/verify-run.ts CHANGED
@@ -3,15 +3,19 @@
3
3
  // command: what a run means — never bail early, count what actually ran — belongs to neither.
4
4
 
5
5
  import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
6
+ import type { CoverageMap } from './coverage-lcov';
7
+ import { encodeCoverage } from './coverage-lcov';
6
8
  import { msg } from './messages';
7
- import type { CommandResult, Finding, StepResult } from './output';
9
+ import type { CommandResult, Finding, JsonValue, StepResult } from './output';
10
+ import { guardStep, raceDeadline, stepTimeoutFinding, stepTimeoutMs } from './verify-deadline';
8
11
  import {
9
12
  floorRequires,
10
13
  readVerifyFloor,
11
14
  skippedSuiteFinding,
12
15
  vanishedSuiteFinding,
13
16
  } from './verify-floor';
14
- import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
17
+ import type { CoverageNumbers, StepOutcome, VerifyContext, VerifyStep } from './verify-step';
18
+ import { GATE_COMMAND } from './verify-step';
15
19
 
16
20
  /**
17
21
  * Run every step, never bailing early: an agent fixing three things at once needs all
@@ -30,6 +34,30 @@ export async function runVerify(
30
34
  const only = onlyList(ctx.only);
31
35
  const selected = only === undefined ? steps : steps.filter((step) => only.includes(step.name));
32
36
  const byName = new Map<string, StepResult>();
37
+ // Per step: the numbers a judged run measured, or — under `--shard`, which cannot judge — the
38
+ // `facts` its slice covered, for `x verify merge` to fold.
39
+ const coverage = new Map<string, JsonValue>();
40
+ // One per run: a step's tag is `<step>@<run>`, so two gates on one machine never kill each
41
+ // other's children.
42
+ const runId = crypto.randomUUID().slice(0, 8);
43
+ const run = async (step: VerifyStep, stepCtx: VerifyContext): Promise<void> => {
44
+ const ran = await runStep(step, stepCtx, floor, runId);
45
+ byName.set(step.name, ran.result);
46
+ if (ran.coverage !== undefined) {
47
+ coverage.set(step.name, { facts: encodeCoverage(ran.coverage) });
48
+ } else if (ran.measured !== undefined) {
49
+ const { lines, funcs, files } = ran.measured;
50
+ coverage.set(step.name, { lines, funcs, files });
51
+ }
52
+ // Told as it finishes, never at the end: the caller streams it, so a cancelled job's log
53
+ // ends on the last step that finished (#589). A progress line is not the verdict: a listener
54
+ // that throws — stderr closed under it — must not take the gate down with it.
55
+ try {
56
+ ctx.onStep?.(ran.result);
57
+ } catch {
58
+ // Nothing to report it on; the document on stdout still carries the step.
59
+ }
60
+ };
33
61
  const began = performance.now();
34
62
  // The static steps wait for the serial suites and then run BESIDE them — only when `live` is in
35
63
  // the list, so a one-step run (`--only`) and a list with no serial suite keep today's order.
@@ -51,16 +79,12 @@ export async function runVerify(
51
79
  for (const step of selected) {
52
80
  if (beside.includes(step)) continue;
53
81
  if (step.name === SERIAL_SUITES[0]) {
54
- pending = Promise.all(
55
- beside.map(async (other) => {
56
- byName.set(other.name, await runStep(other, base, floor));
57
- }),
58
- ).then(() => undefined);
82
+ pending = Promise.all(beside.map((other) => run(other, base))).then(() => undefined);
59
83
  } else if (!SERIAL_SUITES.includes(step.name)) {
60
84
  await join();
61
85
  }
62
86
  const inWindow = pending !== undefined && SERIAL_SUITES.includes(step.name);
63
- byName.set(step.name, await runStep(step, inWindow ? shared : base, floor));
87
+ await run(step, inWindow ? shared : base);
64
88
  }
65
89
  await join();
66
90
  // Reported in the declared order, whatever order the steps finished in: the table, `--json` and
@@ -98,6 +122,7 @@ export async function runVerify(
98
122
  // summary line to learn that this run checked one thing.
99
123
  // `only` is always the LIST, in declared order — one name is a list of one.
100
124
  ...(only === undefined ? {} : { notAGateRun: true, only: [...only] }),
125
+ ...(coverage.size === 0 ? {} : { coverage: Object.fromEntries(coverage) }),
101
126
  },
102
127
  // The step's own status: one step, so `failedSteps` is that step and nothing else.
103
128
  exitCode: failedSteps.length === 0 ? 0 : 1,
@@ -163,14 +188,27 @@ async function runStep(
163
188
  step: VerifyStep,
164
189
  ctx: VerifyContext,
165
190
  floor: Awaited<ReturnType<typeof readVerifyFloor>>,
166
- ): Promise<StepResult> {
191
+ runId: string,
192
+ ): Promise<{
193
+ readonly result: StepResult;
194
+ readonly coverage?: CoverageMap;
195
+ readonly measured?: CoverageNumbers;
196
+ }> {
197
+ const command = ctx.command ?? GATE_COMMAND;
167
198
  // Inside the step's own failure, like a throwing `run`: an `applies` that threw escaped every
168
199
  // catch and aborted the whole gate, which is the one outcome `runVerify` exists to prevent.
169
200
  let applies: boolean;
170
201
  try {
171
202
  applies = step.applies === undefined ? true : await step.applies(ctx);
172
203
  } catch (error) {
173
- return { name: step.name, ok: false, durationMs: 0, findings: [findingOf(error, step.name)] };
204
+ return {
205
+ result: {
206
+ name: step.name,
207
+ ok: false,
208
+ durationMs: 0,
209
+ findings: [findingOf(error, step.name, command)],
210
+ },
211
+ };
174
212
  }
175
213
  if (!applies) {
176
214
  // A skip this repo already ruled out is not a skip. The step ran here before — the floor is
@@ -180,20 +218,36 @@ async function runStep(
180
218
  // the summary, `data.failed`, and the reference-app gate's own red list.
181
219
  const required = floorRequires(floor, step.name);
182
220
  return {
183
- name: step.name,
184
- ok: !required,
185
- durationMs: 0,
186
- skipped: !required,
187
- findings: required ? [vanishedSuiteFinding(step.name)] : [],
221
+ result: {
222
+ name: step.name,
223
+ ok: !required,
224
+ durationMs: 0,
225
+ skipped: !required,
226
+ findings: required ? [vanishedSuiteFinding(step.name, command)] : [],
227
+ },
188
228
  };
189
229
  }
190
230
  const started = performance.now();
191
- const outcome = await step.run(ctx).catch(
192
- (error: unknown): StepOutcome => ({
193
- ok: false,
194
- findings: [findingOf(error, step.name)],
195
- }),
231
+ // The deadline: a step that hangs fails BY NAME and its processes are killed, instead of eating
232
+ // the CI job's whole timeout and leaving a log that says nothing (#589). The step runs on a
233
+ // guarded runner, so everything it starts carries its tag — that is what the kill finds.
234
+ const limit = stepTimeoutMs(step.name, ctx.stepTimeoutMs, floor?.stepTimeoutMs);
235
+ const guard = guardStep(ctx.runner, `${step.name}@${runId}`);
236
+ const raced = await raceDeadline(
237
+ step.run({ ...ctx, runner: guard.runner }).catch(
238
+ (error: unknown): StepOutcome => ({
239
+ ok: false,
240
+ findings: [findingOf(error, step.name, command)],
241
+ }),
242
+ ),
243
+ limit,
196
244
  );
245
+ const outcome: StepOutcome = raced.timedOut
246
+ ? {
247
+ ok: false,
248
+ findings: [stepTimeoutFinding(step.name, limit, await guard.expire(), command)],
249
+ }
250
+ : raced.value;
197
251
  // A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
198
252
  // over an all-skipped file, so the counts are the only channel that can tell the two apart.
199
253
  // ONE definition of "nothing ran", read twice, because the floor decides which of the two
@@ -208,23 +262,27 @@ async function runStep(
208
262
  // step's own findings so `data.failed`, the counts and every gate reading this table carry it.
209
263
  const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
210
264
  return {
211
- name: step.name,
212
- ok: outcome.ok && vanished.length === 0,
213
- durationMs: Math.round(performance.now() - started),
214
- // Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
215
- // the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
216
- // step table may never do — a reader cannot tell a lane that ran from a lane that did not.
217
- skipped: nothingRan && !required,
218
- findings: [...outcome.findings, ...vanished],
219
- ...(outcome.output === undefined ? {} : { output: outcome.output }),
220
- ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
221
- ...(outcome.widthReason === undefined ? {} : { widthReason: outcome.widthReason }),
222
- ...(outcome.shard === undefined ? {} : { shard: outcome.shard }),
223
- ...(tests === undefined ? {} : { tests }),
265
+ result: {
266
+ name: step.name,
267
+ ok: outcome.ok && vanished.length === 0,
268
+ durationMs: Math.round(performance.now() - started),
269
+ // Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
270
+ // the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
271
+ // step table may never do — a reader cannot tell a lane that ran from a lane that did not.
272
+ skipped: nothingRan && !required,
273
+ findings: [...outcome.findings, ...vanished],
274
+ ...(outcome.output === undefined ? {} : { output: outcome.output }),
275
+ ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
276
+ ...(outcome.widthReason === undefined ? {} : { widthReason: outcome.widthReason }),
277
+ ...(outcome.shard === undefined ? {} : { shard: outcome.shard }),
278
+ ...(tests === undefined ? {} : { tests }),
279
+ },
280
+ ...(outcome.coverage === undefined ? {} : { coverage: outcome.coverage }),
281
+ ...(outcome.measured === undefined ? {} : { measured: outcome.measured }),
224
282
  };
225
283
  }
226
284
 
227
- function findingOf(error: unknown, step: string): Finding {
285
+ function findingOf(error: unknown, step: string, command: string): Finding {
228
286
  // A step may throw anything, including an Error that fights being read: `instanceof` runs a
229
287
  // Proxy's `getPrototypeOf` trap and `.message` runs a getter, so a hostile throw would take the
230
288
  // gate's own report down with it — the one message that may never be lost.
@@ -232,7 +290,7 @@ function findingOf(error: unknown, step: string): Finding {
232
290
  return {
233
291
  code: 'X_VERIFY_FAILED',
234
292
  cause: `step "${step}" threw: ${cause}`,
235
- fix: 'x verify --json',
293
+ fix: `${command} --json`,
236
294
  docs: ERROR_DOCS_URL,
237
295
  };
238
296
  }
@@ -10,6 +10,7 @@ import { CryptoHasher } from 'bun';
10
10
  import type { ShardFacts } from './output';
11
11
  import { VerifyShardInvalidError } from './verify-errors';
12
12
  import type { VerifyStepName } from './verify-step';
13
+ import { GATE_COMMAND } from './verify-step';
13
14
 
14
15
  /** The steps a shard may split: the parallel suites. `live` and `e2e` are serial by design. */
15
16
  export const SHARDABLE_STEPS: readonly VerifyStepName[] = ['unit', 'contract', 'job'];
@@ -20,18 +21,22 @@ export interface ShardSpec {
20
21
  readonly total: number;
21
22
  }
22
23
 
23
- /** `3/8` → `{ index: 3, total: 8 }`, refused unless `1 <= i <= n`. */
24
- export function parseShard(raw: string): ShardSpec {
24
+ /**
25
+ * `3/8` → `{ index: 3, total: 8 }`, refused unless `1 <= i <= n`. `command` is the gate as the
26
+ * caller was invoked, so the refusal's fix runs where it was raised.
27
+ */
28
+ export function parseShard(raw: string, command: string = GATE_COMMAND): ShardSpec {
25
29
  const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(raw);
26
30
  const index = match === null ? Number.NaN : Number(match[1]);
27
31
  const total = match === null ? Number.NaN : Number(match[2]);
28
32
  if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || total < 1 || index < 1) {
29
- throw new VerifyShardInvalidError({ reason: `"${raw}" is not i/n (e.g. 2/4)` });
33
+ throw new VerifyShardInvalidError({ reason: `"${raw}" is not i/n (e.g. 2/4)`, command });
30
34
  }
31
35
  if (index > total) {
32
36
  throw new VerifyShardInvalidError({
33
37
  reason: `shard ${String(index)} does not exist in a ${String(total)}-way split (1..${String(total)})`,
34
- fix: `x verify --only unit --shard ${renderFixShellArg(`${String(total)}/${String(total)}`, '<i/n>')} --json`,
38
+ fix: `${command} --only unit --shard ${renderFixShellArg(`${String(total)}/${String(total)}`, '<i/n>')} --json`,
39
+ command,
35
40
  });
36
41
  }
37
42
  return { index, total };
@@ -41,11 +46,14 @@ export function parseShard(raw: string): ShardSpec {
41
46
  * The steps a shard run may name: every one must be shardable. Refused with the step named, and a
42
47
  * fix that keeps the shardable ones — `live`/`e2e` belong in a job of their own.
43
48
  */
44
- export function assertShardable(only: readonly VerifyStepName[] | undefined): void {
49
+ export function assertShardable(
50
+ only: readonly VerifyStepName[] | undefined,
51
+ command: string = GATE_COMMAND,
52
+ ): void {
45
53
  if (only === undefined || only.length === 0) {
46
54
  throw new VerifyShardInvalidError({
47
55
  reason: 'a shard is one slice of ONE parallel step and needs --only to name it',
48
- fix: 'x verify --only unit --shard 1/4 --json',
56
+ command,
49
57
  });
50
58
  }
51
59
  const refused = only.filter((step) => !SHARDABLE_STEPS.includes(step));
@@ -57,8 +65,9 @@ export function assertShardable(only: readonly VerifyStepName[] | undefined): vo
57
65
  } in a job of its own without --shard`,
58
66
  fix:
59
67
  kept.length === 0
60
- ? `x verify --only ${renderFixShellArg(refused.join(','), '<step,step>')} --json`
61
- : `x verify --only ${renderFixShellArg(kept.join(','), '<step,step>')} --shard 1/4 --json`,
68
+ ? `${command} --only ${renderFixShellArg(refused.join(','), '<step,step>')} --json`
69
+ : `${command} --only ${renderFixShellArg(kept.join(','), '<step,step>')} --shard 1/4 --json`,
70
+ command,
62
71
  });
63
72
  }
64
73
  }
@@ -116,12 +125,13 @@ export function shardFiles(
116
125
  }
117
126
 
118
127
  /** A timings file, or a refusal naming it — a CI job must not silently fall back to round-robin. */
119
- export async function readTimings(path: string): Promise<Timings> {
128
+ export async function readTimings(path: string, command: string = GATE_COMMAND): Promise<Timings> {
120
129
  const file = Bun.file(path);
121
130
  if (!(await file.exists())) {
122
131
  throw new VerifyShardInvalidError({
123
132
  reason: `--timings ${path} does not exist`,
124
133
  fix: 'bun test --timings=timings.json --update-timings # writes it, then commit or cache it',
134
+ command,
125
135
  });
126
136
  }
127
137
  let parsed: unknown;
@@ -139,6 +149,7 @@ export async function readTimings(path: string): Promise<Timings> {
139
149
  throw new VerifyShardInvalidError({
140
150
  reason: `--timings ${path} is not a JSON object of { "path": ms }`,
141
151
  fix: 'bun test --timings=timings.json --update-timings',
152
+ command,
142
153
  });
143
154
  }
144
155
  return parsed as Timings;
@@ -3,10 +3,12 @@
3
3
  // implementation can live beside the code it checks without importing the list.
4
4
 
5
5
  import { ERROR_DOCS_URL } from '@ultimat3/core';
6
+ import type { CoverageMap } from './coverage-lcov';
6
7
  import type { ExecResult, Runner } from './exec';
7
8
  import { execOutput } from './exec';
8
- import type { Finding, ShardFacts } from './output';
9
+ import type { Finding, ShardFacts, StepResult } from './output';
9
10
  import type { TestCounts } from './test-counts';
11
+ import type { StepTimeouts } from './verify-deadline';
10
12
 
11
13
  /**
12
14
  * Every step of the gate, in cost order — cheapest and most informative first, and never a check
@@ -106,8 +108,29 @@ export interface VerifyContext {
106
108
  * name is accepted for the callers that narrow to one (`scripts/verify.ts`).
107
109
  */
108
110
  readonly only?: VerifyStepName | readonly VerifyStepName[];
111
+ /**
112
+ * Per-step deadlines in milliseconds that beat `x.verify.json`'s `stepTimeoutMs`, which beat the
113
+ * step class's declared number (`verify-deadline.ts`). A step past its deadline fails by name
114
+ * with `X_VERIFY_STEP_TIMEOUT` and every process it started is killed.
115
+ */
116
+ readonly stepTimeoutMs?: StepTimeouts;
117
+ /**
118
+ * Called as each step finishes, in the order they finish. `--json` prints nothing until the run
119
+ * ends, so a job cancelled mid-run left no trace of where it hung (#589): the command streams
120
+ * one line per step to stderr through this, and stdout stays the single document.
121
+ */
122
+ readonly onStep?: (step: StepResult) => void;
123
+ /**
124
+ * The gate as it was invoked HERE, for a `fix:` that has to run where it was raised: `x verify`
125
+ * when absent, `bun run verify` from the framework root's own entry — where `x verify` answers
126
+ * `X_NOT_IN_APP`.
127
+ */
128
+ readonly command?: string;
109
129
  }
110
130
 
131
+ /** `ctx.command`'s default: the binary and the command, as an app types it. */
132
+ export const GATE_COMMAND = 'x verify';
133
+
111
134
  export interface StepOutcome {
112
135
  readonly ok: boolean;
113
136
  readonly findings: readonly Finding[];
@@ -128,6 +151,24 @@ export interface StepOutcome {
128
151
  * NOT the same as a suite that ran nothing, which is a number the ratchet acts on.
129
152
  */
130
153
  readonly tests?: TestCounts;
154
+ /**
155
+ * What the `unit` suite covered of the app's own tree, when this run did NOT judge it: a
156
+ * `--shard` slice cannot hold a floor, so it hands its facts to `x verify merge`, which folds
157
+ * every shard's and judges once.
158
+ */
159
+ readonly coverage?: CoverageMap;
160
+ /**
161
+ * What the `unit` suite covered of the app's own tree when this run DID judge it. `--json` drops
162
+ * a green step's `output`, so the numbers ride in `data.coverage` where a reader can find them.
163
+ */
164
+ readonly measured?: CoverageNumbers;
165
+ }
166
+
167
+ /** The two percentages a judged run measured, and the source files they are over. */
168
+ export interface CoverageNumbers {
169
+ readonly lines: number;
170
+ readonly funcs: number;
171
+ readonly files: number;
131
172
  }
132
173
 
133
174
  export interface VerifyStep {
@@ -14,6 +14,8 @@ import type { TestType } from '@ultimat3/testing';
14
14
  import { TEST_TYPES } from '@ultimat3/testing';
15
15
  import { checkEvalBaselines, checkEvalCoverage, checkEvalRecording } from './app-evals';
16
16
  import { APP_CONFIG_FILE } from './app-root';
17
+ import { judgeCoverage } from './coverage-floor';
18
+ import { msg } from './messages';
17
19
  import { countsOf } from './test-counts';
18
20
  import { testEnvOverrides } from './test-dotenv';
19
21
  import type { TestFile } from './test-select';
@@ -21,7 +23,9 @@ import { discoverTests } from './test-select';
21
23
  import { machineLease } from './test-slots';
22
24
  import type { WorkerPlan } from './test-workers';
23
25
  import { availableCpus, SERIAL_TYPES, totalMemory, workerPlan } from './test-workers';
26
+ import { runCovered } from './verify-coverage-run';
24
27
  import { withE2eApp } from './verify-e2e';
28
+ import { readVerifyFloor } from './verify-floor';
25
29
  import { shardFiles } from './verify-shard';
26
30
  import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
27
31
  import { fromExec, fromFindings } from './verify-step';
@@ -240,6 +244,21 @@ const runType = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome>
240
244
  ctx.workers === undefined ? workerPlan(availableCpus(), totalMemory(), env) : undefined;
241
245
  const workers = ctx.workers ?? (plan as WorkerPlan).workers;
242
246
  const lease = plan === undefined ? undefined : machineLease(plan.workers, env);
247
+ const widthReason =
248
+ plan === undefined
249
+ ? `${String(Math.min(workers, files.length))} workers (--workers)`
250
+ : files.length < plan.workers
251
+ ? `${String(files.length)} of ${plan.reason}`
252
+ : plan.reason;
253
+ if (type === 'unit' && isApp(ctx.root)) {
254
+ return coveredUnit(ctx, {
255
+ files: files.map((file) => file.path),
256
+ workers,
257
+ widthReason,
258
+ shard,
259
+ lease,
260
+ });
261
+ }
243
262
  const outcome = await runParallel({
244
263
  root: ctx.root,
245
264
  runner: ctx.runner,
@@ -247,18 +266,71 @@ const runType = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome>
247
266
  workers,
248
267
  type,
249
268
  isolate: ctx.isolate === true,
250
- widthReason:
251
- plan === undefined
252
- ? `${String(Math.min(workers, files.length))} workers (--workers)`
253
- : files.length < plan.workers
254
- ? `${String(files.length)} of ${plan.reason}`
255
- : plan.reason,
269
+ widthReason,
256
270
  ...(lease === undefined ? {} : { lease }),
257
271
  ...(ctx.env === undefined ? {} : { env: ctx.env }),
258
272
  });
259
273
  return shard === undefined ? outcome : { ...outcome, shard };
260
274
  };
261
275
 
276
+ /**
277
+ * An APP's unit step: the suite, and what it covered of the app's own source held to the floor in
278
+ * `x.verify.json` (`coverage-floor.ts`). Only the unit suite counts — it is the one every run
279
+ * executes, and an opt-in suite cannot hold a floor a default run must meet. Only an app: the
280
+ * framework's packages are held to the same bar one process each, by `scripts/coverage-gate.ts`,
281
+ * because one run over a monorepo reads every package diluted by the ones it imports.
282
+ *
283
+ * A red suite is not judged — a slice that died wrote no lcov, and a coverage finding beside
284
+ * `X_TEST_FAILED` would send the reader to the wrong fix. A `--shard` slice is not judged either:
285
+ * it hands its facts to `x verify merge`, which folds every shard and judges once.
286
+ */
287
+ const coveredUnit = async (
288
+ ctx: VerifyContext,
289
+ run: {
290
+ readonly files: readonly string[];
291
+ readonly workers: number;
292
+ readonly widthReason: string;
293
+ readonly shard: ReturnType<typeof shardFiles> | undefined;
294
+ readonly lease: ReturnType<typeof machineLease> | undefined;
295
+ },
296
+ ): Promise<StepOutcome> => {
297
+ const { coverage, ...outcome } = await runCovered({
298
+ root: ctx.root,
299
+ runner: ctx.runner,
300
+ files: run.files,
301
+ workers: run.workers,
302
+ isolate: ctx.isolate === true,
303
+ widthReason: run.widthReason,
304
+ ...(run.lease === undefined ? {} : { lease: run.lease }),
305
+ ...(ctx.env === undefined ? {} : { env: ctx.env }),
306
+ });
307
+ if (!outcome.ok) return run.shard === undefined ? outcome : { ...outcome, shard: run.shard };
308
+ if (run.shard !== undefined) {
309
+ return {
310
+ ...outcome,
311
+ shard: run.shard,
312
+ coverage,
313
+ output: msg('cli.verify.coverageDeferred'),
314
+ };
315
+ }
316
+ const verdict = await judgeCoverage(
317
+ ctx.root,
318
+ coverage,
319
+ (await readVerifyFloor(ctx.root))?.coverage,
320
+ );
321
+ return {
322
+ ...outcome,
323
+ ok: verdict.findings.length === 0,
324
+ findings: verdict.findings,
325
+ output: verdict.output,
326
+ measured: {
327
+ lines: verdict.measure.lines,
328
+ funcs: verdict.measure.funcs,
329
+ files: verdict.measure.files,
330
+ },
331
+ };
332
+ };
333
+
262
334
  const isApp = (root: string): boolean => existsSync(join(root, APP_CONFIG_FILE));
263
335
 
264
336
  /**
@@ -10,10 +10,17 @@
10
10
  // public subject. `isReExportManifest` is the whole carve-out — one statement of logic in such a
11
11
  // file re-arms the ceiling on the same save, which is what keeps it from being a hole.
12
12
 
13
- import { existsSync } from 'node:fs';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import { ERROR_DOCS_URL, renderCauseValue } from '@ultimat3/core';
16
16
  import type { Finding } from './output';
17
+ import {
18
+ entryFilesOf,
19
+ FIXTURE_SUFFIX,
20
+ fixtureReachFinding,
21
+ fixturesReachedFrom,
22
+ mayReachFixture,
23
+ } from './publish-closure';
17
24
  import { isReExportManifest } from './reexport-manifest';
18
25
  import { eachSourceFile, isGenerated } from './source-files';
19
26
  import { checkRootReferences } from './tsconfig-references';
@@ -177,6 +184,16 @@ export function checkLockstep(
177
184
  */
178
185
  export const TEST_EXCLUSION = '!src/**/*.test.ts';
179
186
 
187
+ /**
188
+ * The same rule for the other suffix test code is written under. A `-fixture.ts` is a harness a
189
+ * test imports — a fake driver, a recorded page, a scaffolded tree — and `src` sweeps it into the
190
+ * tarball exactly as it sweeps a `*.test.ts`: six packages shipped 18 of them, 2026-10.
191
+ */
192
+ export const FIXTURE_EXCLUSION = '!src/**/*-fixture.ts';
193
+
194
+ /** Every exclusion a published package's `files` carries, in the order it is written. */
195
+ export const TEST_CODE_EXCLUSIONS: readonly string[] = [TEST_EXCLUSION, FIXTURE_EXCLUSION];
196
+
180
197
  export const missingPublishedFileFinding = (dir: string, entry: string): Finding => ({
181
198
  code: 'X_PACKAGE_SHAPE',
182
199
  cause: `packages/${dir}/package.json ships "${entry}" in "files", but packages/${dir}/${entry} does not exist`,
@@ -185,10 +202,10 @@ export const missingPublishedFileFinding = (dir: string, entry: string): Finding
185
202
  at: `packages/${dir}/package.json`,
186
203
  });
187
204
 
188
- export const publishesTestsFinding = (dir: string): Finding => ({
205
+ export const publishesTestsFinding = (dir: string, exclusion: string): Finding => ({
189
206
  code: 'X_PACKAGE_SHAPE',
190
- cause: `packages/${dir}/package.json does not exclude ${TEST_EXCLUSION} from "files"`,
191
- fix: `add "${TEST_EXCLUSION}" to "files" in packages/${dir}/package.json, after "src"`,
207
+ cause: `packages/${dir}/package.json does not exclude ${exclusion} from "files"`,
208
+ fix: `add "${exclusion}" to "files" in packages/${dir}/package.json, after "src"`,
192
209
  docs: ERROR_DOCS_URL,
193
210
  at: `packages/${dir}/package.json`,
194
211
  });
@@ -222,7 +239,7 @@ export const buildArtifactsFinding = (dir: string, count: number): Finding => ({
222
239
  export const noFilesAllowlistFinding = (dir: string): Finding => ({
223
240
  code: 'X_PACKAGE_SHAPE',
224
241
  cause: `packages/${dir}/package.json publishes with no "files" allowlist, so the tarball carries whatever is in the directory`,
225
- fix: `add "files": ["src", "${TEST_EXCLUSION}", "README.md", "LICENSE"] to packages/${dir}/package.json`,
242
+ fix: `add "files": ["src", ${TEST_CODE_EXCLUSIONS.map((entry) => `"${entry}"`).join(', ')}, "README.md", "LICENSE"] to packages/${dir}/package.json`,
226
243
  docs: ERROR_DOCS_URL,
227
244
  at: `packages/${dir}/package.json`,
228
245
  });
@@ -239,7 +256,7 @@ export const noFilesAllowlistFinding = (dir: string): Finding => ({
239
256
  * The second half is `src` sweeping in every `*.test.ts` beside it. Tests are the framework's own,
240
257
  * they run against a preloaded frozen clock and a sealed network, and a consumer's test runner
241
258
  * collecting them is a failure nobody asked for — 393 files, over half of `@ultimat3/cli`'s
242
- * tarball.
259
+ * tarball. A `*-fixture.ts` is the same code under its other suffix (`TEST_CODE_EXCLUSIONS`).
243
260
  *
244
261
  * Private packages are exempt: a generated app's `packages/*` never reach a registry, carry no
245
262
  * `files` and need no license of their own.
@@ -258,8 +275,10 @@ export function checkPublishShape(root: string, manifests: readonly ManifestFact
258
275
  if (existsSync(join(root, 'packages', manifest.dir, entry))) continue;
259
276
  findings.push(missingPublishedFileFinding(manifest.dir, entry));
260
277
  }
261
- if (!manifest.files.includes(TEST_EXCLUSION)) {
262
- findings.push(publishesTestsFinding(manifest.dir));
278
+ for (const exclusion of TEST_CODE_EXCLUSIONS) {
279
+ if (!manifest.files.includes(exclusion)) {
280
+ findings.push(publishesTestsFinding(manifest.dir, exclusion));
281
+ }
263
282
  }
264
283
  }
265
284
  return findings;
@@ -312,6 +331,28 @@ export interface PackageShapeOptions {
312
331
  readonly release?: string;
313
332
  }
314
333
 
334
+ /**
335
+ * The fixtures a published package's entry points reach. A package with no fixture costs one glob
336
+ * — 19 of 31 — and one with some is read once: `mayReachFixture` answers from the text, and only a
337
+ * package it cannot clear pays for the import walk.
338
+ */
339
+ async function fixtureReach(root: string, dir: string, manifest: unknown): Promise<Finding[]> {
340
+ const base = join(root, 'packages', dir);
341
+ const fixtures = new Bun.Glob(`src/**/*${FIXTURE_SUFFIX}`).scan({ cwd: base, absolute: false });
342
+ if ((await fixtures.next()).done === true) return [];
343
+ const entries = entryFilesOf(manifest);
344
+ // What the tarball carries of `src`, plus the fixtures themselves: a test's imports are not a
345
+ // consumer's, so no `*.test.ts` is read.
346
+ const sources = new Map<string, string>();
347
+ for await (const path of new Bun.Glob('src/**/*.{ts,tsx}').scan({ cwd: base, absolute: false })) {
348
+ if (!/\.test\.tsx?$/.test(path)) sources.set(path, readFileSync(join(base, path), 'utf8'));
349
+ }
350
+ if (!mayReachFixture(sources, entries)) return [];
351
+ return fixturesReachedFrom({ read: (path) => sources.get(path) }, entries).map((fixture) =>
352
+ fixtureReachFinding(dir, fixture, entries),
353
+ );
354
+ }
355
+
315
356
  /** Every package ships the same contract files; a missing one is a build error, not a chore. */
316
357
  export async function checkPackageShape(
317
358
  root: string,
@@ -348,6 +389,7 @@ export async function checkPackageShape(
348
389
  findings.push(badVersionFinding(dir, version));
349
390
  continue;
350
391
  }
392
+ if (record.private !== true) findings.push(...(await fixtureReach(root, dir, manifest)));
351
393
  facts.push({
352
394
  dir,
353
395
  name: typeof record.name === 'string' ? record.name : `@ultimat3/${dir}`,