@ultimat3/cli 22.15.0 → 24.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 (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  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/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
@@ -6,55 +6,82 @@
6
6
  // - the zero-tests floor applied to the SUMMED counts of a sharded step, which each shard deferred;
7
7
  // - any gap is a named `X_VERIFY_MERGE_INCOMPLETE` finding, never a silent pass.
8
8
  //
9
+ // - an app's coverage floor judged HERE for a sharded `unit` step: one shard's slice cannot hold a
10
+ // floor, so each hands its facts over in its document and the fold is judged once.
11
+ //
9
12
  // The merged document is a gate run (no `notAGateRun`), because the question it answers — did
10
13
  // every step run, over the whole tree, and pass — is the gate's question.
11
14
 
12
- import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
15
+ // why: Bun ships no synchronous existence check and no path-join primitive.
16
+ import { existsSync } from 'node:fs';
17
+ // why: Bun ships no path-join primitive.
18
+ import { join } from 'node:path';
19
+ import { ERROR_DOCS_URL, renderThrowable, UltimateError } from '@ultimat3/core';
20
+ import { APP_CONFIG_FILE } from './app-root';
21
+ import { judgeCoverage } from './coverage-floor';
22
+ import type { CoverageMap } from './coverage-lcov';
23
+ import { decodeCoverage, mergeCoverage } from './coverage-lcov';
13
24
  import { msg } from './messages';
14
25
  import type { CommandResult, Finding, StepResult } from './output';
15
26
  import type { TestCounts } from './test-counts';
16
- import { VerifyMergeInputError } from './verify-errors';
17
27
  import type { VerifyFloor } from './verify-floor';
18
28
  import { floorRequires, skippedSuiteFinding } from './verify-floor';
29
+ import type { PartStep } from './verify-part-step';
30
+ import { readPartStep } from './verify-part-step';
19
31
  import { verifySummary } from './verify-run';
20
- import { VERIFY_STEP_NAMES } from './verify-step';
32
+ import { corpusHash } from './verify-shard';
33
+ import { GATE_COMMAND, VERIFY_STEP_NAMES } from './verify-step';
21
34
 
22
- interface PartStep {
23
- readonly name: string;
24
- readonly ok: boolean;
25
- readonly durationMs: number;
26
- readonly skipped: boolean;
27
- readonly findings: readonly Finding[];
28
- readonly workers?: number;
29
- readonly tests?: TestCounts;
30
- readonly output?: string;
31
- readonly shard?: {
32
- readonly index: number;
33
- readonly total: number;
34
- readonly corpusHash: string;
35
- readonly files: readonly string[];
36
- };
35
+ /**
36
+ * A part handed to `x verify merge` that is not an `x verify --json` document. `command` is the
37
+ * entry that raised it — `bun run verify` at the framework root, where `x verify` answers
38
+ * `X_NOT_IN_APP` — so the `fix:` runs where it was raised.
39
+ */
40
+ export class VerifyMergeInputError extends UltimateError {
41
+ constructor(input: { file: string; reason: string; sourceError?: unknown; command?: string }) {
42
+ const command = input.command ?? GATE_COMMAND;
43
+ super({
44
+ code: 'X_VERIFY_MERGE_INPUT',
45
+ cause: `${command} merge: ${input.file} ${input.reason}`,
46
+ fix: `${command} --only unit --json > part.json # one document per part, the last line of stdout`,
47
+ ...(input.sourceError === undefined ? {} : { sourceError: input.sourceError }),
48
+ });
49
+ }
37
50
  }
38
51
 
39
52
  export interface VerifyPart {
40
53
  readonly file: string;
41
54
  readonly durationMs: number;
42
55
  readonly steps: readonly PartStep[];
56
+ /** step → what this part's slice of it covered and did not judge (`data.coverage.<step>.facts`). */
57
+ readonly coverage?: Readonly<Record<string, CoverageMap>>;
58
+ }
59
+
60
+ /** Findings a step earns from the parts TOGETHER, which no one part could raise. */
61
+ export type MergeRiders = Readonly<
62
+ Record<string, { readonly findings: readonly Finding[]; readonly output?: string }>
63
+ >;
64
+
65
+ export interface MergeOptions {
66
+ /** The gate as invoked here, for the `fix:` of a gap. */
67
+ readonly command?: string;
68
+ readonly riders?: MergeRiders;
43
69
  }
44
70
 
45
71
  const isRecord = (value: unknown): value is Record<string, unknown> =>
46
72
  typeof value === 'object' && value !== null && !Array.isArray(value);
47
73
 
48
74
  /**
49
- * The LAST non-empty line that parses: `bin/check --json` prints the build's document and then
50
- * the gate's, and a reader takes the last one.
75
+ * The LAST non-empty line, and only it: `bin/check --json` prints the build's document and then
76
+ * the gate's. Never "the last line that parses" — a part whose tail is not the gate's document is
77
+ * a truncated or appended upload, and falling back to an earlier line would believe it.
51
78
  */
52
- export function parsePart(file: string, text: string): VerifyPart {
79
+ export function parsePart(file: string, text: string, command: string = GATE_COMMAND): VerifyPart {
53
80
  const lines = text
54
81
  .split('\n')
55
82
  .map((line) => line.trim())
56
83
  .filter((line) => line.length > 0);
57
- if (lines.length === 0) throw new VerifyMergeInputError({ file, reason: 'is empty' });
84
+ if (lines.length === 0) throw new VerifyMergeInputError({ file, reason: 'is empty', command });
58
85
  let doc: unknown;
59
86
  try {
60
87
  doc = JSON.parse(lines[lines.length - 1] as string);
@@ -63,54 +90,138 @@ export function parsePart(file: string, text: string): VerifyPart {
63
90
  file,
64
91
  reason: `does not end in a JSON document (${renderThrowable(error)})`,
65
92
  sourceError: error,
93
+ command,
66
94
  });
67
95
  }
68
96
  if (!isRecord(doc) || doc['command'] !== 'verify' || !Array.isArray(doc['steps'])) {
69
97
  throw new VerifyMergeInputError({
70
98
  file,
71
99
  reason: 'is not an x verify --json document (no "command": "verify" with "steps")',
100
+ command,
72
101
  });
73
102
  }
74
103
  const data = isRecord(doc['data']) ? doc['data'] : {};
75
104
  const steps: PartStep[] = [];
76
105
  for (const raw of doc['steps'] as unknown[]) {
77
- if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['ok'] !== 'boolean') {
78
- throw new VerifyMergeInputError({ file, reason: 'holds a step without a name and ok' });
106
+ const step = readPartStep(raw);
107
+ if (typeof step === 'string') throw new VerifyMergeInputError({ file, reason: step, command });
108
+ steps.push(step);
109
+ }
110
+ const coverage = new Map<string, CoverageMap>();
111
+ for (const [step, entry] of Object.entries(isRecord(data['coverage']) ? data['coverage'] : {})) {
112
+ // A part that judged its own floor carries numbers, not facts: nothing here to fold.
113
+ if (!isRecord(entry) || !('facts' in entry)) continue;
114
+ const map = decodeCoverage(entry['facts']);
115
+ if (map === undefined) {
116
+ throw new VerifyMergeInputError({
117
+ file,
118
+ reason: `carries coverage for "${step}" that is not a coverage map`,
119
+ command,
120
+ });
79
121
  }
80
- steps.push(raw as unknown as PartStep);
122
+ coverage.set(step, map);
81
123
  }
82
124
  return {
83
125
  file,
84
126
  durationMs: typeof data['durationMs'] === 'number' ? data['durationMs'] : 0,
85
127
  steps,
128
+ ...(coverage.size === 0 ? {} : { coverage: Object.fromEntries(coverage) }),
86
129
  };
87
130
  }
88
131
 
89
- const gap = (cause: string, at?: string): Finding => ({
90
- code: 'X_VERIFY_MERGE_INCOMPLETE',
91
- cause,
92
- fix: 'x verify merge parts/*.json --json # after every CI job uploaded its part',
93
- docs: ERROR_DOCS_URL,
94
- ...(at === undefined ? {} : { at }),
95
- });
132
+ /** A gap finding, with a fix spelled for the entry that raised it. */
133
+ type Gap = (cause: string, at?: string) => Finding;
134
+
135
+ const gapFor =
136
+ (command: string): Gap =>
137
+ (cause, at) => ({
138
+ code: 'X_VERIFY_MERGE_INCOMPLETE',
139
+ cause,
140
+ fix: `${command} merge parts/*.json --json # after every CI job uploaded its part`,
141
+ docs: ERROR_DOCS_URL,
142
+ ...(at === undefined ? {} : { at }),
143
+ });
144
+
145
+ /** The step every part's coverage facts belong to. */
146
+ const COVERED_STEP = 'unit';
147
+
148
+ /**
149
+ * An app's coverage floor over a SHARDED `unit` step, judged once on the fold of every shard —
150
+ * the finding no shard could raise alone. Nothing when the step was not split (the whole run
151
+ * judged itself), when a shard is red (a failed slice wrote no lcov, and its own finding is the
152
+ * one to read), or outside an app. Shards that carry no facts at all fold to an empty map, which
153
+ * is 0% and a red gate: a split can never skip the floor by saying nothing.
154
+ */
155
+ export async function coverageRiders(
156
+ parts: readonly VerifyPart[],
157
+ root: string,
158
+ floor: VerifyFloor | undefined,
159
+ ): Promise<MergeRiders> {
160
+ if (!existsSync(join(root, APP_CONFIG_FILE))) return {};
161
+ const shards = parts.flatMap((part) =>
162
+ part.steps
163
+ .filter((step) => step.name === COVERED_STEP && step.shard !== undefined)
164
+ .map((step) => ({ step, coverage: part.coverage?.[COVERED_STEP] })),
165
+ );
166
+ if (shards.length === 0 || shards.some((shard) => !shard.step.ok)) return {};
167
+ const folded = mergeCoverage(
168
+ shards.flatMap((shard) => (shard.coverage === undefined ? [] : [shard.coverage])),
169
+ );
170
+ const verdict = await judgeCoverage(root, folded, floor?.coverage);
171
+ return { [COVERED_STEP]: { findings: verdict.findings, output: verdict.output } };
172
+ }
96
173
 
97
174
  const sumCounts = (steps: readonly PartStep[]): TestCounts | undefined => {
98
175
  const counted = steps.filter((step) => step.tests !== undefined);
99
176
  if (counted.length === 0) return undefined;
100
- return counted.reduce<TestCounts>(
101
- (sum, step) => ({
102
- ran: sum.ran + (step.tests?.ran ?? 0),
103
- skipped: sum.skipped + (step.tests?.skipped ?? 0),
104
- }),
105
- { ran: 0, skipped: 0 },
106
- );
177
+ const errors = counted.reduce((sum, step) => sum + (step.tests?.errors ?? 0), 0);
178
+ return {
179
+ ran: counted.reduce((sum, step) => sum + (step.tests?.ran ?? 0), 0),
180
+ skipped: counted.reduce((sum, step) => sum + (step.tests?.skipped ?? 0), 0),
181
+ ...(errors === 0 ? {} : { errors }),
182
+ };
107
183
  };
108
184
 
185
+ /**
186
+ * The shards' `files` must PARTITION the corpus they all hashed: each file in exactly one shard,
187
+ * and the union hashing to `corpusHash`. Agreeing on the hash is not agreeing on the split — shard
188
+ * 1/2 round-robin beside 2/2 with `--timings` ran one file twice and another nowhere, green.
189
+ */
190
+ function partitionGaps(
191
+ name: string,
192
+ sharded: readonly { readonly step: PartStep }[],
193
+ gap: Gap,
194
+ ): readonly Finding[] {
195
+ const counts = new Map<string, number>();
196
+ for (const entry of sharded) {
197
+ for (const file of entry.step.shard?.files ?? []) counts.set(file, (counts.get(file) ?? 0) + 1);
198
+ }
199
+ const twice = [...counts].filter(([, count]) => count > 1).map(([file]) => file);
200
+ const found: Finding[] = [];
201
+ if (twice.length > 0) {
202
+ found.push(gap(`step "${name}" ran ${twice.join(', ')} in more than one shard`, name));
203
+ }
204
+ // A list's fingerprint, not a credential: what the shards ran against what they all counted.
205
+ const ranCorpus = corpusHash([...counts.keys()]);
206
+ const countedCorpus = sharded[0]?.step.shard?.corpusHash;
207
+ if (ranCorpus !== countedCorpus) {
208
+ found.push(
209
+ gap(
210
+ `step "${name}"'s shards ran ${String(counts.size)} distinct file(s), which is not the corpus they hashed — some file ran in no shard; every shard must split one list the same way (all with --timings, or none)`,
211
+ name,
212
+ ),
213
+ );
214
+ }
215
+ return found;
216
+ }
217
+
109
218
  /** One step's entries from every part, folded into one step result and its gaps. */
110
219
  function mergeStep(
111
220
  name: string,
112
221
  entries: readonly { readonly part: string; readonly step: PartStep }[],
113
222
  floor: VerifyFloor | undefined,
223
+ gap: Gap,
224
+ rider: MergeRiders[string] | undefined,
114
225
  ): StepResult {
115
226
  if (entries.length === 0) {
116
227
  return {
@@ -166,6 +277,8 @@ function mergeStep(
166
277
  if (doubled.length > 0) {
167
278
  gaps.push(gap(`step "${name}" has shard(s) ${doubled.join(', ')} more than once`, name));
168
279
  }
280
+ // Only over a complete, single-corpus set — any gap above already explains a partial union.
281
+ if (gaps.length === 0) gaps.push(...partitionGaps(name, sharded, gap));
169
282
  }
170
283
  const steps = entries.map((entry) => entry.step);
171
284
  const tests = sumCounts(steps);
@@ -174,16 +287,23 @@ function mergeStep(
174
287
  const nothingRan = sharded.length > 0 && tests !== undefined && tests.ran === 0;
175
288
  const required = floorRequires(floor, name);
176
289
  const floorGap = nothingRan && required ? [skippedSuiteFinding(name, tests.skipped)] : [];
177
- const findings = [...steps.flatMap((step) => step.findings), ...gaps, ...floorGap];
178
- const output = steps
179
- .filter((step) => !step.ok && step.output !== undefined && step.output.length > 0)
180
- .map((step) => step.output as string)
181
- .join('\n');
290
+ const riding = rider?.findings ?? [];
291
+ const findings = [...steps.flatMap((step) => step.findings), ...gaps, ...floorGap, ...riding];
292
+ const output = [
293
+ ...steps
294
+ .filter((step) => !step.ok && step.output !== undefined && step.output.length > 0)
295
+ .map((step) => step.output as string),
296
+ ...(rider?.output === undefined ? [] : [rider.output]),
297
+ ].join('\n');
182
298
  const skipped =
183
299
  sharded.length > 0 ? nothingRan && !required : steps.every((step) => step.skipped);
184
300
  return {
185
301
  name,
186
- ok: steps.every((step) => step.ok) && gaps.length === 0 && floorGap.length === 0,
302
+ ok:
303
+ steps.every((step) => step.ok) &&
304
+ gaps.length === 0 &&
305
+ floorGap.length === 0 &&
306
+ riding.length === 0,
187
307
  // The slowest job: the parts ran side by side, and wall time is what a CI run waits on.
188
308
  durationMs: Math.max(...steps.map((step) => step.durationMs)),
189
309
  ...(skipped ? { skipped: true } : {}),
@@ -198,7 +318,9 @@ export function mergeParts(
198
318
  parts: readonly VerifyPart[],
199
319
  floor: VerifyFloor | undefined,
200
320
  declared: readonly string[] = VERIFY_STEP_NAMES,
321
+ options: MergeOptions = {},
201
322
  ): CommandResult {
323
+ const gap = gapFor(options.command ?? GATE_COMMAND);
202
324
  const unknown = parts.flatMap((part) =>
203
325
  part.steps
204
326
  .filter((step) => !declared.includes(step.name))
@@ -211,6 +333,10 @@ export function mergeParts(
211
333
  part.steps.filter((step) => step.name === name).map((step) => ({ part: part.file, step })),
212
334
  ),
213
335
  floor,
336
+ gap,
337
+ options.riders !== undefined && Object.hasOwn(options.riders, name)
338
+ ? options.riders[name]
339
+ : undefined,
214
340
  ),
215
341
  );
216
342
  const failed = steps.filter((step) => !step.ok).map((step) => step.name);
@@ -0,0 +1,90 @@
1
+ // One step of an `x verify --json` part, read against the shape `mergeStep` relies on. It was a
2
+ // cast past `name` and `ok`: `findings: [null]` merged green, and a step with no `durationMs`
3
+ // reached `Math.max` as `undefined` — a part a CI job hand-edited, or truncated, was believed.
4
+
5
+ import type { Finding } from './output';
6
+ import type { TestCounts } from './test-counts';
7
+
8
+ export interface PartStep {
9
+ readonly name: string;
10
+ readonly ok: boolean;
11
+ readonly durationMs: number;
12
+ readonly skipped: boolean;
13
+ readonly findings: readonly Finding[];
14
+ readonly workers?: number;
15
+ readonly tests?: TestCounts;
16
+ readonly output?: string;
17
+ readonly shard?: {
18
+ readonly index: number;
19
+ readonly total: number;
20
+ readonly corpusHash: string;
21
+ readonly files: readonly string[];
22
+ };
23
+ }
24
+
25
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
26
+ typeof value === 'object' && value !== null && !Array.isArray(value);
27
+
28
+ const isCount = (value: unknown): value is number =>
29
+ typeof value === 'number' && Number.isInteger(value) && value >= 0;
30
+
31
+ /** What every finding is: code, cause and fix as strings. Optional fields ride along as written. */
32
+ const isFinding = (value: unknown): value is Finding =>
33
+ isRecord(value) &&
34
+ typeof value['code'] === 'string' &&
35
+ typeof value['cause'] === 'string' &&
36
+ typeof value['fix'] === 'string';
37
+
38
+ const isTests = (value: unknown): value is TestCounts =>
39
+ isRecord(value) &&
40
+ isCount(value['ran']) &&
41
+ isCount(value['skipped']) &&
42
+ (value['errors'] === undefined || isCount(value['errors']));
43
+
44
+ const isShard = (value: unknown): value is NonNullable<PartStep['shard']> =>
45
+ isRecord(value) &&
46
+ isCount(value['index']) &&
47
+ isCount(value['total']) &&
48
+ typeof value['corpusHash'] === 'string' &&
49
+ Array.isArray(value['files']) &&
50
+ value['files'].every((file) => typeof file === 'string');
51
+
52
+ /** The step, or the reason it is not one — phrased to follow "holds a step …". */
53
+ export function readPartStep(raw: unknown): PartStep | string {
54
+ if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['ok'] !== 'boolean') {
55
+ return 'holds a step without a name and ok';
56
+ }
57
+ const name = raw['name'];
58
+ const durationMs = raw['durationMs'];
59
+ if (typeof durationMs !== 'number' || !Number.isFinite(durationMs) || durationMs < 0) {
60
+ return `holds step "${name}" without a durationMs in milliseconds`;
61
+ }
62
+ const findings = raw['findings'];
63
+ if (!Array.isArray(findings) || !findings.every(isFinding)) {
64
+ return `holds step "${name}" whose findings are not each a code, a cause and a fix`;
65
+ }
66
+ const { tests, shard, output, workers, skipped } = raw;
67
+ if (tests !== undefined && !isTests(tests)) {
68
+ return `holds step "${name}" whose tests are not counts`;
69
+ }
70
+ if (shard !== undefined && !isShard(shard)) {
71
+ return `holds step "${name}" whose shard is not an index, a total, a corpusHash and its files`;
72
+ }
73
+ if (output !== undefined && typeof output !== 'string') {
74
+ return `holds step "${name}" whose output is not text`;
75
+ }
76
+ if (workers !== undefined && !isCount(workers)) {
77
+ return `holds step "${name}" whose workers is not a count`;
78
+ }
79
+ return {
80
+ name,
81
+ ok: raw['ok'],
82
+ durationMs,
83
+ skipped: skipped === true,
84
+ findings,
85
+ ...(workers === undefined ? {} : { workers }),
86
+ ...(tests === undefined ? {} : { tests }),
87
+ ...(output === undefined ? {} : { output }),
88
+ ...(shard === undefined ? {} : { shard }),
89
+ };
90
+ }
@@ -0,0 +1,25 @@
1
+ // One finished gate step as one line of JSON, for stderr. `x verify --json` prints its document
2
+ // when the run ENDS, so a job cancelled mid-run used to leave a log with nothing in it (#589):
3
+ // the last of these lines is the last step that finished, and the hung one is whichever is next.
4
+
5
+ import type { StepResult } from './output';
6
+
7
+ /** `{"step":"lint","ok":true,"ms":21987}` — plus `"skipped":true` for a step that did not apply. */
8
+ export const stepLine = (step: StepResult): string =>
9
+ JSON.stringify({
10
+ step: step.name,
11
+ ok: step.ok,
12
+ ms: step.durationMs,
13
+ ...(step.skipped === true ? { skipped: true } : {}),
14
+ });
15
+
16
+ /**
17
+ * The `onStep` a `--json` run streams through, or nothing: the human render needs no second
18
+ * channel. One function for both entries (`x verify`, and `bun run verify` at the framework
19
+ * root), so the two cannot print different lines.
20
+ */
21
+ export const stepStream = (
22
+ json: boolean,
23
+ write: (line: string) => void,
24
+ ): { readonly onStep?: (step: StepResult) => void } =>
25
+ json ? { onStep: (step) => write(stepLine(step)) } : {};
@@ -0,0 +1,54 @@
1
+ // The child `verify-role-load.ts` spawns: `bun verify-role-load-probe.ts <app root>`. Imports what
2
+ // a worker imports, then every module that load left out — one at a time — and prints what each of
3
+ // those registered that the worker's process did not hold. Run by hand, it is the same answer.
4
+
5
+ import { scanAppModules } from './app-load';
6
+ import { scanBackground } from './role-load';
7
+ import type { MissingRegistration } from './verify-role-load';
8
+ import { PROBE_MARKER, registeredSince, registrations } from './verify-role-load';
9
+ import { writeLine } from './write-line';
10
+
11
+ /**
12
+ * What importing each module the worker load left out registered, by module — `[]` when the whole
13
+ * scan reported a module that would not import. Exported so a test calls it in its own process;
14
+ * the gate runs it as a child, where the registries hold nothing the gate's own load put there.
15
+ */
16
+ export async function probeRoleLoad(root: string): Promise<readonly MissingRegistration[]> {
17
+ const background = await scanBackground(root);
18
+ const imported = new Set(background.files);
19
+ let held = registrations();
20
+ const missing: MissingRegistration[] = [];
21
+ let current: string | undefined;
22
+
23
+ /** Whatever appeared since the last look was registered by importing `current`. */
24
+ const attribute = (): void => {
25
+ const now = registrations();
26
+ if (current !== undefined) {
27
+ for (const one of registeredSince(held, now)) missing.push({ ...one, module: current });
28
+ }
29
+ held = now;
30
+ };
31
+
32
+ // The whole app, in the scan's own order. `include` is asked before each import, so the look it
33
+ // takes is at what the PREVIOUS module added; a module the worker already imported adds nothing.
34
+ const prefix = root.endsWith('/') ? root : `${root}/`;
35
+ const whole = await scanAppModules(root, {
36
+ track: false,
37
+ include: (absolute) => {
38
+ attribute();
39
+ const file = absolute.startsWith(prefix) ? absolute.slice(prefix.length) : absolute;
40
+ current = imported.has(file) ? undefined : file;
41
+ return true;
42
+ },
43
+ });
44
+ attribute();
45
+ // A module that would not import registered nothing in EITHER load, and the gate's own load
46
+ // already reports it: an answer drawn from a short scan would blame the wrong thing.
47
+ return whole.findings.length > 0 ? [] : missing;
48
+ }
49
+
50
+ if (import.meta.main) {
51
+ const answer = await probeRoleLoad(process.argv[2] ?? process.cwd());
52
+ writeLine(`${PROBE_MARKER}${JSON.stringify(answer)}`);
53
+ process.exit(0);
54
+ }
@@ -0,0 +1,148 @@
1
+ // The gate's half of per-role loading (`manifest` step): a worker and a scheduler import the API
2
+ // index and every module that reaches no component or stylesheet (`role-load.ts`). A registration
3
+ // made in a module that DOES reach one, and that the index does not import, exists in the web role
4
+ // and in every tool — and not in a worker. At boot only jobs and tasks are checked, against the
5
+ // manifest; this is the build error for everything else a job reads by name.
6
+
7
+ // why: Bun ships no path API; the fix names the import relative to the API index.
8
+ import { dirname, join, relative } from 'node:path';
9
+ import { ERROR_DOCS_URL, registeredServiceNames, singleLine } from '@ultimat3/core';
10
+ import { entityNames } from '@ultimat3/entity';
11
+ import { catalogDeclarationCount } from '@ultimat3/i18n';
12
+ import { registeredBackfills, registeredJobs, registeredTasks } from '@ultimat3/jobs';
13
+ import { registeredLayouts, registeredMailIds } from '@ultimat3/mail';
14
+ import { registeredChannels } from '@ultimat3/realtime';
15
+ import { definedStorage } from '@ultimat3/storage';
16
+ import { API_INDEX } from './app-root';
17
+ import type { Runner } from './exec';
18
+ import type { Finding } from './output';
19
+ import { quoteArg } from './shell-quote';
20
+
21
+ /** One thing a process registered, as a job would ask for it: by kind and by name. */
22
+ export interface Registration {
23
+ readonly kind: string;
24
+ readonly name: string;
25
+ }
26
+
27
+ /** A registration the whole app makes and the background load does not, and the module it took. */
28
+ export interface MissingRegistration extends Registration {
29
+ /** App-root-relative: the scanned module whose import registered it. */
30
+ readonly module: string;
31
+ }
32
+
33
+ /**
34
+ * Every name-keyed registry a job body can read without importing the declaration: what
35
+ * `ctx.<service>`, `disk('x')`, `t('key')`, a mail layout or a channel resolves through. A
36
+ * registry read only through a static import of its declaration is not here — the import loads it.
37
+ */
38
+ export function registrations(): readonly Registration[] {
39
+ const of = (kind: string, names: readonly string[]): readonly Registration[] =>
40
+ names.map((name) => ({ kind, name }));
41
+ return [
42
+ ...of('service', registeredServiceNames()),
43
+ ...of('storage disk', definedStorage()?.diskNames ?? []),
44
+ // A catalog declaration carries no name of its own: the count is what a module adds to.
45
+ ...of(
46
+ 'catalog declaration',
47
+ Array.from({ length: catalogDeclarationCount() }, (_, at) => `#${String(at + 1)}`),
48
+ ),
49
+ ...of('entity', entityNames()),
50
+ ...of(
51
+ 'job',
52
+ registeredJobs().map((job) => job.name),
53
+ ),
54
+ ...of(
55
+ 'task',
56
+ registeredTasks().map((task) => task.name),
57
+ ),
58
+ ...of(
59
+ 'backfill',
60
+ registeredBackfills().map((backfill) => backfill.name),
61
+ ),
62
+ ...of('mail', registeredMailIds()),
63
+ ...of('mail layout', registeredLayouts()),
64
+ ...of(
65
+ 'channel',
66
+ registeredChannels().map((channel) => channel.name),
67
+ ),
68
+ ];
69
+ }
70
+
71
+ const keyOf = (one: Registration): string => `${one.kind}\u0000${one.name}`;
72
+
73
+ /** What `after` holds that `before` did not. */
74
+ export function registeredSince(
75
+ before: readonly Registration[],
76
+ after: readonly Registration[],
77
+ ): readonly Registration[] {
78
+ const had = new Set(before.map(keyOf));
79
+ return after.filter((one) => !had.has(keyOf(one)));
80
+ }
81
+
82
+ /** The import the API index is missing, as it would be written there. */
83
+ function specifierFor(module: string): string {
84
+ const from = relative(dirname(API_INDEX), module).replace(/\.[jt]sx?$/, '');
85
+ return from.startsWith('.') ? from : `./${from}`;
86
+ }
87
+
88
+ export function roleLoadFinding(missing: MissingRegistration): Finding {
89
+ return {
90
+ code: 'X_ROLE_LOAD_INCOMPLETE',
91
+ cause: `${missing.kind} ${JSON.stringify(missing.name)} is registered by importing ${missing.module}, which reaches a component or a stylesheet and which ${API_INDEX} does not import — so a worker and a scheduler, which import no document, run without it`,
92
+ fix: `edit ${API_INDEX}: add import '${specifierFor(missing.module)}'; — or move the ${missing.kind} out of ${missing.module} into a module that imports no .tsx and no stylesheet`,
93
+ docs: ERROR_DOCS_URL,
94
+ at: missing.module,
95
+ };
96
+ }
97
+
98
+ /** The line the probe prints its answer on; everything else it writes is a module's own output. */
99
+ export const PROBE_MARKER = 'x-role-load-probe:';
100
+
101
+ const PROBE = join(import.meta.dir, 'verify-role-load-probe.ts');
102
+
103
+ const isMissing = (value: unknown): value is MissingRegistration => {
104
+ if (typeof value !== 'object' || value === null) return false;
105
+ const one = value as Record<string, unknown>;
106
+ return (
107
+ typeof one['kind'] === 'string' &&
108
+ typeof one['name'] === 'string' &&
109
+ typeof one['module'] === 'string'
110
+ );
111
+ };
112
+
113
+ /** The probe's answer off its stdout — `undefined` when it printed none. */
114
+ export function parseProbe(stdout: string): readonly MissingRegistration[] | undefined {
115
+ const line = stdout
116
+ .split('\n')
117
+ .reverse()
118
+ .find((one) => one.startsWith(PROBE_MARKER));
119
+ if (line === undefined) return undefined;
120
+ try {
121
+ const parsed: unknown = JSON.parse(line.slice(PROBE_MARKER.length));
122
+ return Array.isArray(parsed) && parsed.every(isMissing) ? parsed : undefined;
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * One child, because the comparison needs a process whose registries hold ONLY what the background
130
+ * load put there — this one has already imported the whole app. The child imports what a worker
131
+ * imports, then the rest module by module, and answers what each of those added.
132
+ */
133
+ export async function roleLoadFindings(root: string, runner: Runner): Promise<readonly Finding[]> {
134
+ const result = await runner(['bun', PROBE, root], { cwd: root });
135
+ const missing = parseProbe(result.stdout);
136
+ if (missing !== undefined) return missing.map(roleLoadFinding);
137
+ // No answer is not a pass: the load a worker performs did not complete in a clean process.
138
+ const said = singleLine(result.stderr.trim().split('\n').at(-1) ?? '');
139
+ return [
140
+ {
141
+ code: 'X_ROLE_LOAD_INCOMPLETE',
142
+ cause: `the worker's load of this app did not complete in a process of its own (exit ${String(result.code)}${said === '' ? '' : `: ${said}`}), so what a worker registers could not be compared with the whole app`,
143
+ fix: `bun ${quoteArg(PROBE)} ${quoteArg(root)}`,
144
+ docs: ERROR_DOCS_URL,
145
+ at: API_INDEX,
146
+ },
147
+ ];
148
+ }