@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
@@ -6,10 +6,21 @@
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
 
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';
12
19
  import { ERROR_DOCS_URL, renderThrowable } 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';
@@ -17,7 +28,7 @@ import { VerifyMergeInputError } from './verify-errors';
17
28
  import type { VerifyFloor } from './verify-floor';
18
29
  import { floorRequires, skippedSuiteFinding } from './verify-floor';
19
30
  import { verifySummary } from './verify-run';
20
- import { VERIFY_STEP_NAMES } from './verify-step';
31
+ import { GATE_COMMAND, VERIFY_STEP_NAMES } from './verify-step';
21
32
 
22
33
  interface PartStep {
23
34
  readonly name: string;
@@ -40,6 +51,19 @@ export interface VerifyPart {
40
51
  readonly file: string;
41
52
  readonly durationMs: number;
42
53
  readonly steps: readonly PartStep[];
54
+ /** step → what this part's slice of it covered and did not judge (`data.coverage.<step>.facts`). */
55
+ readonly coverage?: Readonly<Record<string, CoverageMap>>;
56
+ }
57
+
58
+ /** Findings a step earns from the parts TOGETHER, which no one part could raise. */
59
+ export type MergeRiders = Readonly<
60
+ Record<string, { readonly findings: readonly Finding[]; readonly output?: string }>
61
+ >;
62
+
63
+ export interface MergeOptions {
64
+ /** The gate as invoked here, for the `fix:` of a gap. */
65
+ readonly command?: string;
66
+ readonly riders?: MergeRiders;
43
67
  }
44
68
 
45
69
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -49,12 +73,12 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
49
73
  * The LAST non-empty line that parses: `bin/check --json` prints the build's document and then
50
74
  * the gate's, and a reader takes the last one.
51
75
  */
52
- export function parsePart(file: string, text: string): VerifyPart {
76
+ export function parsePart(file: string, text: string, command: string = GATE_COMMAND): VerifyPart {
53
77
  const lines = text
54
78
  .split('\n')
55
79
  .map((line) => line.trim())
56
80
  .filter((line) => line.length > 0);
57
- if (lines.length === 0) throw new VerifyMergeInputError({ file, reason: 'is empty' });
81
+ if (lines.length === 0) throw new VerifyMergeInputError({ file, reason: 'is empty', command });
58
82
  let doc: unknown;
59
83
  try {
60
84
  doc = JSON.parse(lines[lines.length - 1] as string);
@@ -63,47 +87,101 @@ export function parsePart(file: string, text: string): VerifyPart {
63
87
  file,
64
88
  reason: `does not end in a JSON document (${renderThrowable(error)})`,
65
89
  sourceError: error,
90
+ command,
66
91
  });
67
92
  }
68
93
  if (!isRecord(doc) || doc['command'] !== 'verify' || !Array.isArray(doc['steps'])) {
69
94
  throw new VerifyMergeInputError({
70
95
  file,
71
96
  reason: 'is not an x verify --json document (no "command": "verify" with "steps")',
97
+ command,
72
98
  });
73
99
  }
74
100
  const data = isRecord(doc['data']) ? doc['data'] : {};
75
101
  const steps: PartStep[] = [];
76
102
  for (const raw of doc['steps'] as unknown[]) {
77
103
  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' });
104
+ throw new VerifyMergeInputError({
105
+ file,
106
+ reason: 'holds a step without a name and ok',
107
+ command,
108
+ });
79
109
  }
80
110
  steps.push(raw as unknown as PartStep);
81
111
  }
112
+ const coverage = new Map<string, CoverageMap>();
113
+ for (const [step, entry] of Object.entries(isRecord(data['coverage']) ? data['coverage'] : {})) {
114
+ // A part that judged its own floor carries numbers, not facts: nothing here to fold.
115
+ if (!isRecord(entry) || !('facts' in entry)) continue;
116
+ const map = decodeCoverage(entry['facts']);
117
+ if (map === undefined) {
118
+ throw new VerifyMergeInputError({
119
+ file,
120
+ reason: `carries coverage for "${step}" that is not a coverage map`,
121
+ command,
122
+ });
123
+ }
124
+ coverage.set(step, map);
125
+ }
82
126
  return {
83
127
  file,
84
128
  durationMs: typeof data['durationMs'] === 'number' ? data['durationMs'] : 0,
85
129
  steps,
130
+ ...(coverage.size === 0 ? {} : { coverage: Object.fromEntries(coverage) }),
86
131
  };
87
132
  }
88
133
 
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
- });
134
+ /** A gap finding, with a fix spelled for the entry that raised it. */
135
+ type Gap = (cause: string, at?: string) => Finding;
136
+
137
+ const gapFor =
138
+ (command: string): Gap =>
139
+ (cause, at) => ({
140
+ code: 'X_VERIFY_MERGE_INCOMPLETE',
141
+ cause,
142
+ fix: `${command} merge parts/*.json --json # after every CI job uploaded its part`,
143
+ docs: ERROR_DOCS_URL,
144
+ ...(at === undefined ? {} : { at }),
145
+ });
146
+
147
+ /** The step every part's coverage facts belong to. */
148
+ const COVERED_STEP = 'unit';
149
+
150
+ /**
151
+ * An app's coverage floor over a SHARDED `unit` step, judged once on the fold of every shard —
152
+ * the finding no shard could raise alone. Nothing when the step was not split (the whole run
153
+ * judged itself), when a shard is red (a failed slice wrote no lcov, and its own finding is the
154
+ * one to read), or outside an app. Shards that carry no facts at all fold to an empty map, which
155
+ * is 0% and a red gate: a split can never skip the floor by saying nothing.
156
+ */
157
+ export async function coverageRiders(
158
+ parts: readonly VerifyPart[],
159
+ root: string,
160
+ floor: VerifyFloor | undefined,
161
+ ): Promise<MergeRiders> {
162
+ if (!existsSync(join(root, APP_CONFIG_FILE))) return {};
163
+ const shards = parts.flatMap((part) =>
164
+ part.steps
165
+ .filter((step) => step.name === COVERED_STEP && step.shard !== undefined)
166
+ .map((step) => ({ step, coverage: part.coverage?.[COVERED_STEP] })),
167
+ );
168
+ if (shards.length === 0 || shards.some((shard) => !shard.step.ok)) return {};
169
+ const folded = mergeCoverage(
170
+ shards.flatMap((shard) => (shard.coverage === undefined ? [] : [shard.coverage])),
171
+ );
172
+ const verdict = await judgeCoverage(root, folded, floor?.coverage);
173
+ return { [COVERED_STEP]: { findings: verdict.findings, output: verdict.output } };
174
+ }
96
175
 
97
176
  const sumCounts = (steps: readonly PartStep[]): TestCounts | undefined => {
98
177
  const counted = steps.filter((step) => step.tests !== undefined);
99
178
  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
- );
179
+ const errors = counted.reduce((sum, step) => sum + (step.tests?.errors ?? 0), 0);
180
+ return {
181
+ ran: counted.reduce((sum, step) => sum + (step.tests?.ran ?? 0), 0),
182
+ skipped: counted.reduce((sum, step) => sum + (step.tests?.skipped ?? 0), 0),
183
+ ...(errors === 0 ? {} : { errors }),
184
+ };
107
185
  };
108
186
 
109
187
  /** One step's entries from every part, folded into one step result and its gaps. */
@@ -111,6 +189,8 @@ function mergeStep(
111
189
  name: string,
112
190
  entries: readonly { readonly part: string; readonly step: PartStep }[],
113
191
  floor: VerifyFloor | undefined,
192
+ gap: Gap,
193
+ rider: MergeRiders[string] | undefined,
114
194
  ): StepResult {
115
195
  if (entries.length === 0) {
116
196
  return {
@@ -174,16 +254,23 @@ function mergeStep(
174
254
  const nothingRan = sharded.length > 0 && tests !== undefined && tests.ran === 0;
175
255
  const required = floorRequires(floor, name);
176
256
  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');
257
+ const riding = rider?.findings ?? [];
258
+ const findings = [...steps.flatMap((step) => step.findings), ...gaps, ...floorGap, ...riding];
259
+ const output = [
260
+ ...steps
261
+ .filter((step) => !step.ok && step.output !== undefined && step.output.length > 0)
262
+ .map((step) => step.output as string),
263
+ ...(rider?.output === undefined ? [] : [rider.output]),
264
+ ].join('\n');
182
265
  const skipped =
183
266
  sharded.length > 0 ? nothingRan && !required : steps.every((step) => step.skipped);
184
267
  return {
185
268
  name,
186
- ok: steps.every((step) => step.ok) && gaps.length === 0 && floorGap.length === 0,
269
+ ok:
270
+ steps.every((step) => step.ok) &&
271
+ gaps.length === 0 &&
272
+ floorGap.length === 0 &&
273
+ riding.length === 0,
187
274
  // The slowest job: the parts ran side by side, and wall time is what a CI run waits on.
188
275
  durationMs: Math.max(...steps.map((step) => step.durationMs)),
189
276
  ...(skipped ? { skipped: true } : {}),
@@ -198,7 +285,9 @@ export function mergeParts(
198
285
  parts: readonly VerifyPart[],
199
286
  floor: VerifyFloor | undefined,
200
287
  declared: readonly string[] = VERIFY_STEP_NAMES,
288
+ options: MergeOptions = {},
201
289
  ): CommandResult {
290
+ const gap = gapFor(options.command ?? GATE_COMMAND);
202
291
  const unknown = parts.flatMap((part) =>
203
292
  part.steps
204
293
  .filter((step) => !declared.includes(step.name))
@@ -211,6 +300,10 @@ export function mergeParts(
211
300
  part.steps.filter((step) => step.name === name).map((step) => ({ part: part.file, step })),
212
301
  ),
213
302
  floor,
303
+ gap,
304
+ options.riders !== undefined && Object.hasOwn(options.riders, name)
305
+ ? options.riders[name]
306
+ : undefined,
214
307
  ),
215
308
  );
216
309
  const failed = steps.filter((step) => !step.ok).map((step) => step.name);
@@ -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
+ }