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