@ultimat3/cli 22.15.0 → 23.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +23 -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 +25 -1
- 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-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 +9 -0
- 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/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/permission-grants.ts +41 -9
- 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 +6 -0
- 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 -157
- 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/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-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
|
+
}
|