@ultimat3/cli 22.14.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.
- package/CLAUDE.md +25 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +35 -3
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-dev.ts +2 -2
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +30 -6
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/document-options.ts +64 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/page-speculation.ts +124 -0
- package/src/permission-grants.ts +41 -9
- package/src/prerender.ts +9 -5
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +36 -60
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -147
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/static-document.ts +108 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
package/src/verify-merge.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}),
|
|
105
|
-
|
|
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
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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:
|
|
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
|
+
}
|