@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/cmd-secrets.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from '@ultimat3/core';
|
|
31
31
|
import { ENV_SCHEMA_EXPORT, loadEnvSchema } from './app-env';
|
|
32
32
|
import { requireAppRoot } from './app-root';
|
|
33
|
+
import { appSecrets, dropRetired, retire, retiredKeyIds } from './cmd-secrets-ring';
|
|
33
34
|
import { secretsSpec } from './cmd-secrets-spec';
|
|
34
35
|
import type { CliCommand, CommandContext } from './command';
|
|
35
36
|
import {
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
} from './errors';
|
|
41
42
|
import { msg } from './messages';
|
|
42
43
|
import type { CommandResult, JsonValue } from './output';
|
|
44
|
+
import { flagString } from './parse';
|
|
43
45
|
import { recoverRotation, rotateMasterKey } from './secrets-rotation';
|
|
44
46
|
import { shredOnSignal } from './signal-shred';
|
|
45
47
|
import { renderTable } from './table';
|
|
@@ -244,36 +246,65 @@ async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
|
|
|
244
246
|
};
|
|
245
247
|
}
|
|
246
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The closing half of a rotation: one retired key leaves the ring, by id, once nothing sealed
|
|
251
|
+
* under it is left. No new key is generated and the key file is not touched — the committed file
|
|
252
|
+
* is resealed under the key already in force.
|
|
253
|
+
*/
|
|
254
|
+
async function drop(session: Session, keyId: string): Promise<CommandResult> {
|
|
255
|
+
const { root, key } = session;
|
|
256
|
+
const after = await dropRetired(await readSecretsFile(root, key), keyId);
|
|
257
|
+
await writeSecretsFile(root, after, key);
|
|
258
|
+
const retired = await retiredKeyIds(after);
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
command: 'secrets',
|
|
262
|
+
summary: msg('cli.secrets.dropped', { path: SECRETS_FILE, kid: keyId, count: retired.length }),
|
|
263
|
+
data: {
|
|
264
|
+
path: SECRETS_FILE,
|
|
265
|
+
keyId: await masterKeyIdOf(key),
|
|
266
|
+
dropped: keyId,
|
|
267
|
+
retiredKeyIds: [...retired],
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
247
272
|
/**
|
|
248
273
|
* A new master key over the same values: staged beside the old one, the file sealed with it, then
|
|
249
274
|
* made live (`rotateMasterKey`). Sealed first with the key file written last, a crash between the
|
|
250
275
|
* two left a committed file no key on disk could open.
|
|
276
|
+
*
|
|
277
|
+
* The key being replaced joins the ring INSIDE the sealed file (`cmd-secrets-ring.ts`), so a value
|
|
278
|
+
* `seal()` wrote under it still opens and a deploy is still handed exactly one key.
|
|
251
279
|
*/
|
|
252
280
|
async function rotate(ctx: CommandContext): Promise<CommandResult> {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
281
|
+
const session = await open(ctx, 'rotate');
|
|
282
|
+
const dropping = flagString(ctx.args, 'drop');
|
|
283
|
+
if (dropping !== undefined) return drop(session, dropping);
|
|
284
|
+
const { root, key } = session;
|
|
285
|
+
const values = retire(await readSecretsFile(root, key), key.hex);
|
|
255
286
|
const previous = await masterKeyIdOf(key);
|
|
256
287
|
// Ignored BEFORE any key file is written, staged one included; the order inside is
|
|
257
288
|
// `secrets-rotation.ts`'s: stage the key, seal, then rename it live.
|
|
258
289
|
await ensureIgnored(root);
|
|
259
290
|
const next = await rotateMasterKey(root, values);
|
|
260
291
|
const keyId = await masterKeyIdOf(next);
|
|
292
|
+
const count = Object.keys(appSecrets(values)).length;
|
|
261
293
|
return {
|
|
262
294
|
ok: true,
|
|
263
295
|
command: 'secrets',
|
|
264
|
-
summary: msg('cli.secrets.rotated', {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}),
|
|
270
|
-
lines: [msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE })],
|
|
296
|
+
summary: msg('cli.secrets.rotated', { path: SECRETS_FILE, from: previous, to: keyId, count }),
|
|
297
|
+
lines: [
|
|
298
|
+
msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE }),
|
|
299
|
+
msg('cli.secrets.retired', { kid: previous }),
|
|
300
|
+
],
|
|
271
301
|
data: {
|
|
272
302
|
path: SECRETS_FILE,
|
|
273
303
|
keyPath: SECRETS_KEY_FILE,
|
|
274
304
|
previousKeyId: previous,
|
|
275
305
|
keyId,
|
|
276
|
-
count
|
|
306
|
+
count,
|
|
307
|
+
retiredKeyIds: [...(await retiredKeyIds(values))],
|
|
277
308
|
},
|
|
278
309
|
};
|
|
279
310
|
}
|
|
@@ -285,9 +316,12 @@ async function rotate(ctx: CommandContext): Promise<CommandResult> {
|
|
|
285
316
|
*/
|
|
286
317
|
async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
287
318
|
const { root, key } = await open(ctx, 'show');
|
|
288
|
-
const
|
|
319
|
+
const sealed = await readSecretsFile(root, key);
|
|
320
|
+
// The ring is the framework's own entry: reported by key id below, never as an app secret that
|
|
321
|
+
// `envSchema` forgot to declare.
|
|
322
|
+
const retired = await retiredKeyIds(sealed);
|
|
289
323
|
const schema = await loadEnvSchema(root);
|
|
290
|
-
const summaries = describeSecrets(
|
|
324
|
+
const summaries = describeSecrets(appSecrets(sealed));
|
|
291
325
|
const keyId = await masterKeyIdOf(key);
|
|
292
326
|
const declared = (name: string): boolean | undefined =>
|
|
293
327
|
schema === undefined ? undefined : schema[name] !== undefined;
|
|
@@ -309,6 +343,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
|
309
343
|
: [
|
|
310
344
|
msg('cli.secrets.undeclared', { count: undeclared.length, names: undeclared.join(', ') }),
|
|
311
345
|
]),
|
|
346
|
+
...(retired.length === 0 ? [] : [msg('cli.secrets.ring', { kids: retired.join(', ') })]),
|
|
312
347
|
];
|
|
313
348
|
return {
|
|
314
349
|
ok: true,
|
|
@@ -322,6 +357,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
|
322
357
|
path: SECRETS_FILE,
|
|
323
358
|
keySource: key.source,
|
|
324
359
|
keyId,
|
|
360
|
+
retiredKeyIds: [...retired],
|
|
325
361
|
count: summaries.length,
|
|
326
362
|
secrets: summaries.map((entry) => ({
|
|
327
363
|
name: entry.name,
|
package/src/cmd-tasks.ts
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
// `x tasks [list|show <name>]` — introspect scheduled cron tasks: cadence, timezone, the jobs
|
|
2
|
-
// each enqueues,
|
|
3
|
-
//
|
|
4
|
-
// `
|
|
2
|
+
// each enqueues, the occurrence the scheduler LAST dispatched (off the queue's own record) and
|
|
3
|
+
// real next-occurrence instants from `@ultimat3/time`'s cron math instead of an agent reading
|
|
4
|
+
// `0 3 * * *` and guessing. CLI wiring only; the pure computation lives in `tasks-facts.ts` — the
|
|
5
|
+
// same split `cmd-jobs.ts` makes against `jobs-report.ts`.
|
|
5
6
|
|
|
6
7
|
import { nearestName, systemClock } from '@ultimat3/core';
|
|
7
|
-
import type { TaskHandle } from '@ultimat3/jobs';
|
|
8
|
+
import type { JobDriver, TaskFire, TaskHandle } from '@ultimat3/jobs';
|
|
8
9
|
import type { CronPhrases } from '@ultimat3/time';
|
|
9
10
|
import { loadApp } from './app-load';
|
|
10
11
|
import { requireAppRoot } from './app-root';
|
|
11
12
|
import { tasksSpec } from './cmd-tasks-spec';
|
|
12
13
|
import type { CliCommand, CommandContext } from './command';
|
|
13
14
|
import { BadFlagError, DeclarationUnknownError } from './errors';
|
|
15
|
+
import { withJobDriver } from './jobs-driver';
|
|
14
16
|
import { msg } from './messages';
|
|
15
17
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
16
18
|
import { flagString } from './parse';
|
|
17
19
|
import { renderTable } from './table';
|
|
18
20
|
import {
|
|
19
21
|
findTaskHandle,
|
|
22
|
+
isoInZone,
|
|
20
23
|
knownTaskNames,
|
|
21
24
|
listTaskFacts,
|
|
22
25
|
parseCountFlag,
|
|
@@ -24,7 +27,41 @@ import {
|
|
|
24
27
|
taskShowFacts,
|
|
25
28
|
} from './tasks-facts';
|
|
26
29
|
|
|
27
|
-
const HEADER = ['name', 'cron', 'tz', 'catchUp', 'jobs', 'next'] as const;
|
|
30
|
+
const HEADER = ['name', 'cron', 'tz', 'catchUp', 'jobs', 'last', 'next'] as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* When a task last fired, flat beside `nextMs` / `next`. All three are `null` for a task the
|
|
34
|
+
* scheduler has never dispatched — a key that is sometimes absent is a question asked two ways.
|
|
35
|
+
*/
|
|
36
|
+
interface LastFire {
|
|
37
|
+
/** Epoch ms of the occurrence it was scheduled FOR — the instant `last` renders. */
|
|
38
|
+
readonly lastMs: number | null;
|
|
39
|
+
readonly last: string | null;
|
|
40
|
+
/** Epoch ms the scheduler dispatched it, on the queue's clock. Later than `lastMs` by its lag. */
|
|
41
|
+
readonly lastFiredAtMs: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const NEVER_FIRED: LastFire = { lastMs: null, last: null, lastFiredAtMs: null };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The last occurrence each task DISPATCHED, by task name — `JobIntrospection.taskFires()`, never
|
|
48
|
+
* the scheduler's watermark, which arming and skipping move too. A driver with no introspection
|
|
49
|
+
* answers none, and every task then reads as never fired.
|
|
50
|
+
*/
|
|
51
|
+
async function lastFires(driver: JobDriver): Promise<ReadonlyMap<string, TaskFire>> {
|
|
52
|
+
const fires = (await driver.introspect?.taskFires()) ?? [];
|
|
53
|
+
return new Map(fires.map((fire) => [fire.task, fire]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lastFireOf = (fires: ReadonlyMap<string, TaskFire>, name: string, tz: string): LastFire => {
|
|
57
|
+
const fire = fires.get(name);
|
|
58
|
+
if (fire === undefined) return NEVER_FIRED;
|
|
59
|
+
return {
|
|
60
|
+
lastMs: fire.occurrenceMs,
|
|
61
|
+
last: isoInZone(fire.occurrenceMs, tz),
|
|
62
|
+
lastFiredAtMs: fire.firedAt,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
28
65
|
|
|
29
66
|
/**
|
|
30
67
|
* The vocabulary `describeCron` interpolates. The cron *math* stays in `tasks-facts.ts` — it is
|
|
@@ -57,17 +94,25 @@ const detailLines = (payload: Readonly<Record<string, JsonValue>>): readonly str
|
|
|
57
94
|
|
|
58
95
|
const jobsCell = (jobs: readonly string[]): string => (jobs.length === 0 ? '-' : jobs.join(','));
|
|
59
96
|
|
|
60
|
-
const row = (fact: TaskFact): readonly string[] => [
|
|
97
|
+
const row = (fact: TaskFact & LastFire): readonly string[] => [
|
|
61
98
|
fact.name,
|
|
62
99
|
fact.cron,
|
|
63
100
|
fact.tz,
|
|
64
101
|
fact.catchUp,
|
|
65
102
|
jobsCell(fact.jobs),
|
|
103
|
+
fact.last ?? '-',
|
|
66
104
|
fact.next,
|
|
67
105
|
];
|
|
68
106
|
|
|
69
|
-
function runList(
|
|
70
|
-
|
|
107
|
+
function runList(
|
|
108
|
+
nowMs: number,
|
|
109
|
+
fires: ReadonlyMap<string, TaskFire>,
|
|
110
|
+
findings: readonly Finding[],
|
|
111
|
+
): CommandResult {
|
|
112
|
+
const facts = listTaskFacts(nowMs).map((fact) => ({
|
|
113
|
+
...fact,
|
|
114
|
+
...lastFireOf(fires, fact.name, fact.tz),
|
|
115
|
+
}));
|
|
71
116
|
return {
|
|
72
117
|
ok: findings.length === 0,
|
|
73
118
|
command: 'tasks',
|
|
@@ -101,13 +146,23 @@ function requireHandle(ctx: CommandContext): TaskHandle {
|
|
|
101
146
|
);
|
|
102
147
|
}
|
|
103
148
|
|
|
104
|
-
function runShow(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
149
|
+
function runShow(
|
|
150
|
+
shown: { readonly handle: TaskHandle; readonly count: number },
|
|
151
|
+
nowMs: number,
|
|
152
|
+
fires: ReadonlyMap<string, TaskFire>,
|
|
153
|
+
findings: readonly Finding[],
|
|
154
|
+
): CommandResult {
|
|
155
|
+
const { descriptor, describe, upcoming } = taskShowFacts(
|
|
156
|
+
shown.handle,
|
|
157
|
+
nowMs,
|
|
158
|
+
shown.count,
|
|
159
|
+
cronPhrases(),
|
|
160
|
+
);
|
|
108
161
|
const first = upcoming[0];
|
|
162
|
+
const fired = lastFireOf(fires, descriptor.name, descriptor.tz);
|
|
109
163
|
const lines = [
|
|
110
164
|
...detailLines(asJson(descriptor)),
|
|
165
|
+
` last: ${fired.last ?? '-'}`,
|
|
111
166
|
` ${describe}`,
|
|
112
167
|
...upcoming.map((occurrence) => ` ${occurrence.at}`),
|
|
113
168
|
];
|
|
@@ -124,6 +179,7 @@ function runShow(ctx: CommandContext, nowMs: number, findings: readonly Finding[
|
|
|
124
179
|
findings,
|
|
125
180
|
data: {
|
|
126
181
|
...asJson(descriptor),
|
|
182
|
+
...fired,
|
|
127
183
|
describe,
|
|
128
184
|
upcoming: upcoming.map((occurrence) => asJson(occurrence)),
|
|
129
185
|
},
|
|
@@ -136,8 +192,21 @@ export const tasksCommand: CliCommand = {
|
|
|
136
192
|
const root = requireAppRoot('tasks', ctx.cwd).dir;
|
|
137
193
|
const { findings } = await loadApp(root);
|
|
138
194
|
const nowMs = systemClock.now().getTime();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
195
|
+
// Everything argv can get wrong is refused BEFORE the queue is opened: a typo in a task name
|
|
196
|
+
// must not cost a database boot to be told about.
|
|
197
|
+
const shown =
|
|
198
|
+
ctx.args.subcommand === 'show'
|
|
199
|
+
? { handle: requireHandle(ctx), count: parseCountFlag(flagString(ctx.args, 'count')) }
|
|
200
|
+
: undefined;
|
|
201
|
+
// Nothing to join a fire to: an app that declares no task never pays for a queue.
|
|
202
|
+
if (shown === undefined && knownTaskNames().length === 0) {
|
|
203
|
+
return runList(nowMs, new Map(), findings);
|
|
204
|
+
}
|
|
205
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
206
|
+
const fires = await lastFires(driver);
|
|
207
|
+
return shown === undefined
|
|
208
|
+
? runList(nowMs, fires, findings)
|
|
209
|
+
: runShow(shown, nowMs, fires, findings);
|
|
210
|
+
});
|
|
142
211
|
},
|
|
143
212
|
};
|
package/src/cmd-verify.ts
CHANGED
|
@@ -23,12 +23,14 @@ import { VERIFY_STEPS } from './verify-checks';
|
|
|
23
23
|
import { VerifyMergeInputError, VerifyShardInvalidError } from './verify-errors';
|
|
24
24
|
import { readVerifyFloor } from './verify-floor';
|
|
25
25
|
import type { VerifyPart } from './verify-merge';
|
|
26
|
-
import { mergeParts, parsePart } from './verify-merge';
|
|
26
|
+
import { coverageRiders, mergeParts, parsePart } from './verify-merge';
|
|
27
|
+
import { stepStream } from './verify-progress';
|
|
27
28
|
import { runVerify } from './verify-run';
|
|
28
29
|
import type { ShardSpec, Timings } from './verify-shard';
|
|
29
30
|
import { assertShardable, parseShard, readTimings } from './verify-shard';
|
|
30
31
|
import type { VerifyStepName } from './verify-step';
|
|
31
32
|
import { VERIFY_STEP_NAMES } from './verify-step';
|
|
33
|
+
import { writeErrorLine } from './write-line';
|
|
32
34
|
|
|
33
35
|
// One import path for the gate, unchanged by the split: `index.ts`, `x build` and the MCP host all
|
|
34
36
|
// reach the list and the runner through this module, and a second path to either would be the
|
|
@@ -55,6 +57,9 @@ export const verifyCommand: CliCommand = {
|
|
|
55
57
|
...(only === undefined ? {} : { only }),
|
|
56
58
|
...(shard === undefined ? {} : { shard }),
|
|
57
59
|
...(isolate === undefined ? {} : { isolate }),
|
|
60
|
+
// `--json` prints one document when the run ends; a line per finished step on stderr is
|
|
61
|
+
// what a cancelled CI job's log ends on (#589). stdout stays the single document.
|
|
62
|
+
...stepStream(ctx.args.json, writeErrorLine),
|
|
58
63
|
});
|
|
59
64
|
return shard === undefined ? result : withShardData(result, shard);
|
|
60
65
|
},
|
|
@@ -119,7 +124,10 @@ async function mergeCommand(
|
|
|
119
124
|
}
|
|
120
125
|
parts.push(parsePart(file, await handle.text()));
|
|
121
126
|
}
|
|
122
|
-
|
|
127
|
+
const floor = await readVerifyFloor(root);
|
|
128
|
+
return mergeParts(parts, floor, VERIFY_STEP_NAMES, {
|
|
129
|
+
riders: await coverageRiders(parts, root, floor),
|
|
130
|
+
});
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
/**
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A path read off the repository being scanned, made safe to ride in the `#` comment of a `fix:`
|
|
2
|
+
// line. One definition: every finding that names a scanned file in its fix writes it through this.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Control characters, `\u00xx`-escaped. The path is a value read off the repository being scanned,
|
|
6
|
+
* and it rides in a `#` comment: a directory holding a NEWLINE ends that comment, so everything
|
|
7
|
+
* after it is a second command in a line whose whole purpose is to be pasted into a shell.
|
|
8
|
+
* Escaped rather than deleted, because the comment still has to name the file the reader owns.
|
|
9
|
+
*/
|
|
10
|
+
export const commentSafe = (path: string): string =>
|
|
11
|
+
[...path]
|
|
12
|
+
.map((char) => {
|
|
13
|
+
const code = char.codePointAt(0) ?? 0;
|
|
14
|
+
return code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, '0')}` : char;
|
|
15
|
+
})
|
|
16
|
+
.join('');
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// The coverage floor an app states in `x.verify.json`, what the unit suite measured against the
|
|
2
|
+
// app's WHOLE source tree, and the findings where the two disagree. A finding of the `unit` step,
|
|
3
|
+
// never a step of its own: the step list is as stable as an error code.
|
|
4
|
+
|
|
5
|
+
// why: Bun ships no synchronous existence check and no path-join primitive; the fix a
|
|
6
|
+
// below-floor finding carries depends on whether the test beside the worst file exists.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
// why: Bun ships no path-join primitive.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
11
|
+
import type { CoverageMap } from './coverage-lcov';
|
|
12
|
+
import { percent } from './coverage-lcov';
|
|
13
|
+
import { appSourceFiles, hasExecutableCode, readSource, unloadedWeight } from './coverage-source';
|
|
14
|
+
import { msg } from './messages';
|
|
15
|
+
import type { Finding } from './output';
|
|
16
|
+
import { quoteArg } from './shell-quote';
|
|
17
|
+
|
|
18
|
+
/** The `x.verify.json` key. */
|
|
19
|
+
export const COVERAGE_FIELD = 'coverage';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lines and functions, the framework's own code and every app's — one bar. **A floor, not the
|
|
23
|
+
* goal**: coverage measures execution, not validation, and a test added to raise it is proven by
|
|
24
|
+
* mutation (break the source, watch it go red, restore). A covered branch whose test cannot fail
|
|
25
|
+
* is worse than an uncovered one.
|
|
26
|
+
*/
|
|
27
|
+
export const COVERAGE_BAR = 95;
|
|
28
|
+
|
|
29
|
+
/** How far above a floor under the bar the tree may measure before the floor has to rise. */
|
|
30
|
+
export const FLOOR_SLACK = 1.5;
|
|
31
|
+
|
|
32
|
+
/** How many files a below-floor finding names. */
|
|
33
|
+
export const LOSS_LIMIT = 10;
|
|
34
|
+
|
|
35
|
+
const FLOOR_FILE = 'x.verify.json';
|
|
36
|
+
|
|
37
|
+
export interface CoverageExclude {
|
|
38
|
+
/** Relative to the app root. */
|
|
39
|
+
readonly glob: string;
|
|
40
|
+
/** Why a unit test cannot execute it — a browser-only mount, a container entry point. */
|
|
41
|
+
readonly why: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CoverageFloor {
|
|
45
|
+
readonly lines: number;
|
|
46
|
+
readonly funcs: number;
|
|
47
|
+
/** Required while either number is under `COVERAGE_BAR`: what is uncovered, who closes it. */
|
|
48
|
+
readonly why?: string;
|
|
49
|
+
readonly exclude: readonly CoverageExclude[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isPercent = (value: unknown): value is number =>
|
|
53
|
+
typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100;
|
|
54
|
+
|
|
55
|
+
const isText = (value: unknown): value is string =>
|
|
56
|
+
typeof value === 'string' && value.trim().length > 0;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `coverage` out of `x.verify.json`, or the reasons it is not a floor. A number that is not a
|
|
60
|
+
* percentage is refused rather than read as zero — `measured < NaN` is false for every reading,
|
|
61
|
+
* so a typo would stop the floor enforcing and report green. An `exclude` entry with no `why` is
|
|
62
|
+
* refused too, and DROPPED: it excludes nothing until it says what it is for.
|
|
63
|
+
*/
|
|
64
|
+
export function readCoverageFloor(payload: Record<string, unknown> | undefined): {
|
|
65
|
+
coverage?: CoverageFloor;
|
|
66
|
+
problems: readonly string[];
|
|
67
|
+
} {
|
|
68
|
+
const raw = payload?.[COVERAGE_FIELD];
|
|
69
|
+
if (raw === undefined) return { problems: [] };
|
|
70
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
71
|
+
return { problems: [`"${COVERAGE_FIELD}" is not an object of { "lines", "funcs" }`] };
|
|
72
|
+
}
|
|
73
|
+
const record = raw as Record<string, unknown>;
|
|
74
|
+
const problems: string[] = [];
|
|
75
|
+
for (const key of ['lines', 'funcs'] as const) {
|
|
76
|
+
if (!isPercent(record[key])) {
|
|
77
|
+
problems.push(
|
|
78
|
+
`"${COVERAGE_FIELD}.${key}" is ${JSON.stringify(record[key])}, which is not a percentage from 0 to 100`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (record['why'] !== undefined && !isText(record['why'])) {
|
|
83
|
+
problems.push(`"${COVERAGE_FIELD}.why" is not a sentence`);
|
|
84
|
+
}
|
|
85
|
+
const exclude: CoverageExclude[] = [];
|
|
86
|
+
const list = record['exclude'];
|
|
87
|
+
if (list !== undefined && !Array.isArray(list)) {
|
|
88
|
+
problems.push(`"${COVERAGE_FIELD}.exclude" is not a list of { "glob", "why" }`);
|
|
89
|
+
}
|
|
90
|
+
for (const entry of Array.isArray(list) ? (list as unknown[]) : []) {
|
|
91
|
+
const one =
|
|
92
|
+
typeof entry === 'object' && entry !== null ? (entry as Record<string, unknown>) : {};
|
|
93
|
+
if (!isText(one['glob'])) {
|
|
94
|
+
problems.push(`"${COVERAGE_FIELD}.exclude" holds an entry with no "glob"`);
|
|
95
|
+
} else if (!isText(one['why'])) {
|
|
96
|
+
problems.push(
|
|
97
|
+
`"${COVERAGE_FIELD}.exclude" excludes ${one['glob']} with no "why" — say why a unit test cannot execute it`,
|
|
98
|
+
);
|
|
99
|
+
} else exclude.push({ glob: one['glob'], why: one['why'] });
|
|
100
|
+
}
|
|
101
|
+
if (!isPercent(record['lines']) || !isPercent(record['funcs'])) return { problems };
|
|
102
|
+
return {
|
|
103
|
+
coverage: {
|
|
104
|
+
lines: record['lines'],
|
|
105
|
+
funcs: record['funcs'],
|
|
106
|
+
...(isText(record['why']) ? { why: record['why'] } : {}),
|
|
107
|
+
exclude,
|
|
108
|
+
},
|
|
109
|
+
problems,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One source file's share of the gap. */
|
|
114
|
+
export interface FileLoss {
|
|
115
|
+
readonly file: string;
|
|
116
|
+
/** Lines that could have run and did not. */
|
|
117
|
+
readonly uncovered: number;
|
|
118
|
+
readonly lines: number;
|
|
119
|
+
/** No unit test loaded it at all: it counts at 0%, weighed off its source. */
|
|
120
|
+
readonly unloaded: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface CoverageMeasure {
|
|
124
|
+
readonly lines: number;
|
|
125
|
+
readonly funcs: number;
|
|
126
|
+
readonly linesHit: number;
|
|
127
|
+
readonly linesFound: number;
|
|
128
|
+
readonly funcsHit: number;
|
|
129
|
+
readonly funcsFound: number;
|
|
130
|
+
/** Source files counted, loaded or not. */
|
|
131
|
+
readonly files: number;
|
|
132
|
+
/** Worst first: most uncovered lines, then by path. */
|
|
133
|
+
readonly losses: readonly FileLoss[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The suite's coverage of the app's WHOLE source tree. `coverage` is keyed by path relative to
|
|
138
|
+
* `root`; a record for anything else — a framework package, `node_modules`, a test — is not the
|
|
139
|
+
* app's source and is dropped. A source file with executable code and no record counts at 0%.
|
|
140
|
+
*/
|
|
141
|
+
export async function measureCoverage(
|
|
142
|
+
root: string,
|
|
143
|
+
coverage: CoverageMap,
|
|
144
|
+
floor: CoverageFloor | undefined,
|
|
145
|
+
): Promise<CoverageMeasure> {
|
|
146
|
+
const sources = appSourceFiles(root, floor?.exclude.map((entry) => entry.glob) ?? []);
|
|
147
|
+
let linesHit = 0;
|
|
148
|
+
let linesFound = 0;
|
|
149
|
+
let funcsHit = 0;
|
|
150
|
+
let funcsFound = 0;
|
|
151
|
+
let files = 0;
|
|
152
|
+
const losses: FileLoss[] = [];
|
|
153
|
+
for (const file of sources) {
|
|
154
|
+
const loaded = Object.hasOwn(coverage, file) ? coverage[file] : undefined;
|
|
155
|
+
if (loaded !== undefined) {
|
|
156
|
+
const found = loaded.hit.length + loaded.miss.length;
|
|
157
|
+
linesHit += loaded.hit.length;
|
|
158
|
+
linesFound += found;
|
|
159
|
+
funcsHit += Math.min(loaded.funcsHit, loaded.funcsFound);
|
|
160
|
+
funcsFound += loaded.funcsFound;
|
|
161
|
+
files += 1;
|
|
162
|
+
if (loaded.miss.length > 0) {
|
|
163
|
+
losses.push({ file, uncovered: loaded.miss.length, lines: found, unloaded: false });
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const source = await readSource(root, file);
|
|
168
|
+
// A barrel or a types-only module emits nothing, so Bun writes no record for it when it IS
|
|
169
|
+
// loaded: absent is correct, and it weighs nothing.
|
|
170
|
+
if (source === undefined || !hasExecutableCode(source)) continue;
|
|
171
|
+
const weight = unloadedWeight(source);
|
|
172
|
+
linesFound += weight.lines;
|
|
173
|
+
funcsFound += weight.funcs;
|
|
174
|
+
files += 1;
|
|
175
|
+
losses.push({ file, uncovered: weight.lines, lines: weight.lines, unloaded: true });
|
|
176
|
+
}
|
|
177
|
+
losses.sort((a, b) => b.uncovered - a.uncovered || (a.file < b.file ? -1 : 1));
|
|
178
|
+
return {
|
|
179
|
+
lines: percent(linesHit, linesFound),
|
|
180
|
+
funcs: percent(funcsHit, funcsFound),
|
|
181
|
+
linesHit,
|
|
182
|
+
linesFound,
|
|
183
|
+
funcsHit,
|
|
184
|
+
funcsFound,
|
|
185
|
+
files,
|
|
186
|
+
losses,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One decimal, rounded DOWN: a printed number never claims more than was measured. */
|
|
191
|
+
const shown = (value: number): string => (Math.floor(value * 10) / 10).toFixed(1);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The floor a measured number supports: the bar once it clears it, the whole number under it
|
|
195
|
+
* below — up to a point of room, so one uncovered line in an unrelated file is not a red gate,
|
|
196
|
+
* and less than `FLOOR_SLACK`, so the floor it writes is never stale the moment it is written.
|
|
197
|
+
*/
|
|
198
|
+
const floorFor = (measured: number): number =>
|
|
199
|
+
measured >= COVERAGE_BAR ? COVERAGE_BAR : Math.floor(measured);
|
|
200
|
+
|
|
201
|
+
const WHY_PLACEHOLDER = '<what is uncovered and who closes it>';
|
|
202
|
+
|
|
203
|
+
/** The `"coverage": { … }` text for a measured tree — what an author pastes. */
|
|
204
|
+
export function floorLine(measure: Pick<CoverageMeasure, 'lines' | 'funcs'>): string {
|
|
205
|
+
const lines = floorFor(measure.lines);
|
|
206
|
+
const funcs = floorFor(measure.funcs);
|
|
207
|
+
const why = lines < COVERAGE_BAR || funcs < COVERAGE_BAR ? `, "why": "${WHY_PLACEHOLDER}"` : '';
|
|
208
|
+
return `"${COVERAGE_FIELD}": { "lines": ${String(lines)}, "funcs": ${String(funcs)}${why} }`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `foo.ts` → `foo.test.ts`, and `page.tsx` → `page.test.ts`: where the test that covers a file
|
|
213
|
+
* lives. Always `.ts` — every generator writes it so, and a test holds no JSX: the registry leak
|
|
214
|
+
* guard's loader covers `.test.ts` only, so a `.test.tsx` this named would also be unguarded.
|
|
215
|
+
*/
|
|
216
|
+
export const testPathOf = (file: string): string => file.replace(/\.tsx?$/, '.test.ts');
|
|
217
|
+
|
|
218
|
+
export interface JudgeOptions {
|
|
219
|
+
/** Whether `path` (relative to the app root) exists — decides which fix a below-floor gets. */
|
|
220
|
+
readonly exists: (path: string) => boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The findings a measurement earns against the stated floor — none is the only pass.
|
|
225
|
+
*
|
|
226
|
+
* | State | Finding |
|
|
227
|
+
* |---|---|
|
|
228
|
+
* | no floor stated | `X_COVERAGE_FLOOR_UNSTATED`, carrying the line to add |
|
|
229
|
+
* | a floor under the bar with no `why` | `X_COVERAGE_FLOOR_UNSTATED` |
|
|
230
|
+
* | measured under the floor | `X_COVERAGE_BELOW_FLOOR`, naming the files that lose the most |
|
|
231
|
+
* | a floor under the bar, and the tree `FLOOR_SLACK` points over it | `X_COVERAGE_FLOOR_STALE` |
|
|
232
|
+
*
|
|
233
|
+
* The last row is what makes the floor only rise, and it needs no history to do it: a floor may
|
|
234
|
+
* sit under the bar only while it is what the tree measures.
|
|
235
|
+
*/
|
|
236
|
+
export function coverageFindings(
|
|
237
|
+
measure: CoverageMeasure,
|
|
238
|
+
floor: CoverageFloor | undefined,
|
|
239
|
+
options: JudgeOptions,
|
|
240
|
+
): readonly Finding[] {
|
|
241
|
+
const measured = `${shown(measure.lines)}% of lines and ${shown(measure.funcs)}% of functions`;
|
|
242
|
+
if (floor === undefined) {
|
|
243
|
+
return [
|
|
244
|
+
{
|
|
245
|
+
code: 'X_COVERAGE_FLOOR_UNSTATED',
|
|
246
|
+
cause: `${FLOOR_FILE} states no coverage floor; the unit suite covers ${measured} of this app's own source (${String(measure.files)} files, one no test loads counted at 0%)`,
|
|
247
|
+
fix: `edit ${FLOOR_FILE} — add ${floorLine(measure)}`,
|
|
248
|
+
docs: ERROR_DOCS_URL,
|
|
249
|
+
at: FLOOR_FILE,
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
const findings: Finding[] = [];
|
|
254
|
+
const stated = `${shown(floor.lines)}% / ${shown(floor.funcs)}%`;
|
|
255
|
+
const underBar = floor.lines < COVERAGE_BAR || floor.funcs < COVERAGE_BAR;
|
|
256
|
+
if (underBar && floor.why === undefined) {
|
|
257
|
+
findings.push({
|
|
258
|
+
code: 'X_COVERAGE_FLOOR_UNSTATED',
|
|
259
|
+
cause: `${FLOOR_FILE} states a coverage floor of ${stated}, under the ${String(COVERAGE_BAR)}% bar, and no reason for it`,
|
|
260
|
+
fix: `edit ${FLOOR_FILE} — add "why": "${WHY_PLACEHOLDER}" inside "${COVERAGE_FIELD}", or raise both numbers to ${String(COVERAGE_BAR)}`,
|
|
261
|
+
docs: ERROR_DOCS_URL,
|
|
262
|
+
at: FLOOR_FILE,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (measure.lines < floor.lines || measure.funcs < floor.funcs) {
|
|
266
|
+
const worst = measure.losses.slice(0, LOSS_LIMIT);
|
|
267
|
+
const named = worst
|
|
268
|
+
.map((loss) =>
|
|
269
|
+
loss.unloaded
|
|
270
|
+
? `${loss.file} (no unit test loads it: ${String(loss.lines)} lines)`
|
|
271
|
+
: `${loss.file} (${String(loss.uncovered)} of ${String(loss.lines)} lines)`,
|
|
272
|
+
)
|
|
273
|
+
.join(', ');
|
|
274
|
+
const first = worst[0];
|
|
275
|
+
const test = first === undefined ? undefined : testPathOf(first.file);
|
|
276
|
+
findings.push({
|
|
277
|
+
code: 'X_COVERAGE_BELOW_FLOOR',
|
|
278
|
+
cause: `the unit suite covers ${measured} of this app's own source, under the floor of ${stated} in ${FLOOR_FILE}${named === '' ? '' : ` — losing the most lines: ${named}`}`,
|
|
279
|
+
fix:
|
|
280
|
+
test === undefined
|
|
281
|
+
? 'x verify --only unit --json'
|
|
282
|
+
: options.exists(test)
|
|
283
|
+
? // Quoted: a route directory is `[id]`, which a shell reads as a glob.
|
|
284
|
+
`bun test --coverage ${quoteArg(test)}`
|
|
285
|
+
: `edit ${test} — no test sits beside ${first?.file ?? test}; write one that fails when the source is broken, then: bun test --coverage ${quoteArg(test)}`,
|
|
286
|
+
docs: ERROR_DOCS_URL,
|
|
287
|
+
...(first === undefined ? {} : { at: first.file }),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const risen = (['lines', 'funcs'] as const).filter(
|
|
291
|
+
(key) => floor[key] < COVERAGE_BAR && measure[key] >= floor[key] + FLOOR_SLACK,
|
|
292
|
+
);
|
|
293
|
+
if (risen.length > 0) {
|
|
294
|
+
const lines = Math.max(floor.lines, floorFor(measure.lines));
|
|
295
|
+
const funcs = Math.max(floor.funcs, floorFor(measure.funcs));
|
|
296
|
+
const cleared = lines >= COVERAGE_BAR && funcs >= COVERAGE_BAR;
|
|
297
|
+
findings.push({
|
|
298
|
+
code: 'X_COVERAGE_FLOOR_STALE',
|
|
299
|
+
cause: `the unit suite now covers ${measured}, more than ${String(FLOOR_SLACK)} points over the floor of ${stated} in ${FLOOR_FILE} — the floor only rises`,
|
|
300
|
+
fix: `edit ${FLOOR_FILE} — set "lines": ${String(lines)} and "funcs": ${String(funcs)} inside "${COVERAGE_FIELD}"${cleared ? ', and delete its "why"' : ''}`,
|
|
301
|
+
docs: ERROR_DOCS_URL,
|
|
302
|
+
at: FLOOR_FILE,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return findings;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface CoverageVerdict {
|
|
309
|
+
readonly measure: CoverageMeasure;
|
|
310
|
+
readonly findings: readonly Finding[];
|
|
311
|
+
/** One line for the step's `output`: the numbers, and the floor they were held to. */
|
|
312
|
+
readonly output: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Measure `coverage` against the app at `root` and judge it against `floor` — the whole check. */
|
|
316
|
+
export async function judgeCoverage(
|
|
317
|
+
root: string,
|
|
318
|
+
coverage: CoverageMap,
|
|
319
|
+
floor: CoverageFloor | undefined,
|
|
320
|
+
): Promise<CoverageVerdict> {
|
|
321
|
+
const measure = await measureCoverage(root, coverage, floor);
|
|
322
|
+
return {
|
|
323
|
+
measure,
|
|
324
|
+
findings: coverageFindings(measure, floor, {
|
|
325
|
+
exists: (path) => existsSync(join(root, path)),
|
|
326
|
+
}),
|
|
327
|
+
output: msg('cli.verify.coverage', {
|
|
328
|
+
lines: shown(measure.lines),
|
|
329
|
+
funcs: shown(measure.funcs),
|
|
330
|
+
files: measure.files,
|
|
331
|
+
floor: floor === undefined ? 'unstated' : `${shown(floor.lines)}% / ${shown(floor.funcs)}%`,
|
|
332
|
+
}),
|
|
333
|
+
};
|
|
334
|
+
}
|